Skip to content

Commit 729decd

Browse files
committed
fix(webapp): carry the magic-link email in a cookie instead of the URL
- Pass the confirmation email to /login/magic in a short-lived httpOnly cookie rather than a ?email= query param, so the address never lands in access logs, browser history, or error-tracker breadcrumbs. - Send the action's validation-failure, rate-limit, and auth-failure paths straight to /login instead of /login/magic (which the loader guard would bounce again), matching the reset path.
1 parent 37e9df0 commit 729decd

1 file changed

Lines changed: 36 additions & 11 deletions

File tree

apps/webapp/app/routes/login.magic/route.tsx

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ArrowLeftIcon } from "@heroicons/react/20/solid";
22
import { InboxArrowDownIcon } from "@heroicons/react/24/solid";
33
import {
4+
createCookie,
45
redirect,
56
type ActionFunctionArgs,
67
type LoaderFunctionArgs,
@@ -35,6 +36,17 @@ import { logger, tryCatch } from "@trigger.dev/core/v3";
3536
import { env } from "~/env.server";
3637
import { extractClientIp } from "~/utils/extractClientIp.server";
3738

39+
// The submitted email is carried to the confirmation screen in a short-lived,
40+
// httpOnly cookie rather than the URL, so the address never lands in access
41+
// logs, browser history, or error-tracker breadcrumbs.
42+
const magicLinkEmailCookie = createCookie("magiclink-email", {
43+
maxAge: 60 * 10,
44+
httpOnly: true,
45+
sameSite: "lax",
46+
secure: env.NODE_ENV === "production",
47+
path: "/",
48+
});
49+
3850
export const meta: MetaFunction = ({ matches }) => {
3951
const parentMeta = matches
4052
.flatMap((match) => match.meta ?? [])
@@ -65,18 +77,21 @@ export async function loader({ request }: LoaderFunctionArgs) {
6577
// "magic link sent" confirmation. A visit without a pending magic link
6678
// forwards to /login — keeping the inlined form the single source of truth,
6779
// avoiding an orphaned duplicate page, and letting /login surface any flashed
68-
// auth:error (e.g. an invalid-email submit that never sent a link). The guard
80+
// auth:error. The guard
6981
// runs before reading auth:error so that error isn't consumed here before
7082
// /login can show it. An expired/invalid link click (routes/magic.tsx) is
7183
// different: the email-link strategy only clears the magic-link key on a
7284
// successful verify, so the key is still set and the request lands here — the
7385
// confirmation renders the flashed error as magicLinkError below.
7486
const url = new URL(request.url);
7587
const sanitized = sanitizeRedirectPath(url.searchParams.get("redirectTo"));
76-
// The submitted address is carried on the success redirect so the
77-
// confirmation can name it. Validate before echoing it back.
78-
const emailParam = url.searchParams.get("email");
79-
const email = emailParam && z.string().email().safeParse(emailParam).success ? emailParam : null;
88+
// The submitted address is carried in a short-lived cookie (not the URL) so
89+
// the confirmation can name it. Validate before echoing it back.
90+
const emailCookie = await magicLinkEmailCookie.parse(request.headers.get("Cookie"));
91+
const email =
92+
typeof emailCookie === "string" && z.string().email().safeParse(emailCookie).success
93+
? emailCookie
94+
: null;
8095
if (!session.has("triggerdotdev:magiclink")) {
8196
// Throw (not return) so the redirect doesn't widen the loader's return
8297
// type — otherwise useTypedLoaderData sees TypedResponse<never> in the
@@ -141,7 +156,7 @@ export async function action({ request }: ActionFunctionArgs) {
141156
message: "Please enter a valid email address.",
142157
});
143158

144-
return redirect("/login/magic", {
159+
return redirect("/login", {
145160
headers: {
146161
"Set-Cookie": await commitSession(session),
147162
},
@@ -191,7 +206,7 @@ export async function action({ request }: ActionFunctionArgs) {
191206
message: errorMessage,
192207
});
193208

194-
return redirect("/login/magic", {
209+
return redirect("/login", {
195210
headers: {
196211
"Set-Cookie": await commitSession(session),
197212
},
@@ -211,10 +226,20 @@ export async function action({ request }: ActionFunctionArgs) {
211226
return redirect(ssoRedirect);
212227
}
213228

214-
return authenticator.authenticate("email-link", request, {
215-
successRedirect: `/login/magic?email=${encodeURIComponent(email)}`,
216-
failureRedirect: "/login/magic",
217-
});
229+
// authenticator.authenticate throws its redirect Response; attach the
230+
// sent-to email as a short-lived cookie so the confirmation can name it
231+
// without putting the address in the URL.
232+
try {
233+
return await authenticator.authenticate("email-link", request, {
234+
successRedirect: "/login/magic",
235+
failureRedirect: "/login",
236+
});
237+
} catch (thrown) {
238+
if (thrown instanceof Response) {
239+
thrown.headers.append("Set-Cookie", await magicLinkEmailCookie.serialize(email));
240+
}
241+
throw thrown;
242+
}
218243
}
219244
case "reset":
220245
default: {

0 commit comments

Comments
 (0)