Supabase Magic Links: The Callback That Has to Set a Cookie
signInWithOtp sends the link; the click has to land somewhere a cookie can be set. The PKCE callback this storefront ships, and the cross-device failure.
A Supabase magic link is one call — supabase.auth.signInWithOtp({ email }) — plus one endpoint that has to exist. The emailed link lands on your site carrying a code, and that code must be exchanged for a session somewhere that can write a cookie: a Route Handler or a Server Action. A Server Component render cannot, which is where most broken magic-link flows actually break.
The whole flow, in five hops
- Your form calls
signInWithOtp({ email, options: { emailRedirectTo } }). - Supabase Auth generates a one-time token and emails the link.
- The user clicks it; Supabase verifies the token and redirects to your callback with a
?code=. - Your callback calls
exchangeCodeForSession(code)and the auth cookies get set on the response. - You redirect to wherever the user was going.
Hop 4 is the one with a hard constraint. The options for hop 1 are worth reading from the types you actually have installed rather than from a tutorial — in @supabase/auth-js, SignInWithPasswordlessCredentials carries exactly four:
await supabase.auth.signInWithOtp({
email,
options: {
// The redirect url embedded in the email link
emailRedirectTo: `${SITE_URL}/auth/callback?next=/dashboard`,
// If set to false, this method will not create a new user. Defaults to true.
shouldCreateUser: false,
data: { source: "pricing-page" },
captchaToken,
},
});
shouldCreateUser defaults to true, so a plain signInWithOtp call is a sign-up form as much as a sign-in form. If your product has paid tiers and you did not intend that, set it to false and handle the "no such account" case yourself.
The callback, as it ships here
This storefront's callback is 26 lines and serves three different flows, because the mechanism is identical for all of them. Verbatim from src/app/auth/callback/route.ts:
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { safeNext } from "@/lib/validation";
/**
* PKCE code-exchange callback, shared by: email confirmation (signUp),
* Google OAuth (signInWithOAuth), and password recovery (the reset-password
* page forwards its `?code` here, since only a Route Handler/Server Action
* can persist the session cookie — a Server Component render cannot).
*/
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
const next = safeNext(searchParams.get("next"));
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
redirect(`${origin}${next}`);
}
console.error("auth/callback: exchangeCodeForSession error", error);
}
redirect(`${origin}/login?error=auth`);
}
Three things in there are load-bearing for a magic link too, not just for OAuth:
nextgoes throughsafeNext. The post-login destination arrives in a query string a stranger can edit, so it is validated against same-origin relative paths before any redirect uses it. An unvalidatednextis an open redirect, and a login endpoint is the worst place to have one.- Failure is generic. A bad or expired code sends the visitor to
/login?error=authwith no detail. "This link expired" versus "no such account" is an account-enumeration difference, and the same reasoning governs the sign-in action insrc/lib/actions/auth.ts, which returns one message for every failure. - One route, three senders. Signup confirmation, Google, and password recovery all land here. Adding magic links adds no new endpoint — only a new call site.
The failure nobody warns you about: another device
PKCE is the reason magic links feel flaky. When the browser asks for the link, the client stores a code_verifier locally. The exchange at hop 4 needs that verifier. Request the link on your laptop, open it on your phone, and the verifier is not there — the exchange fails and the user sees your generic error after doing everything right.
For a flow where cross-device is normal, the token-hash route avoids the verifier entirely. verifyOtp accepts the hash straight from the email link:
const { error } = await supabase.auth.verifyOtp({
token_hash: searchParams.get("token_hash")!,
type: "email",
});
That requires a custom email template emitting {{ .TokenHash }} instead of the default confirmation URL. It is more setup, and it is the right setup if your audience reads email on a different device from the one they work on — which, for a B2B product, is most of them.
Why this storefront does not use magic links
src/lib/actions/auth.ts exports six server actions: signInWithPassword, signUpWithPassword, signInWithGoogle, requestPasswordReset, updatePassword, signOut. Calls to signInWithOtp: zero. That is a decision, and the reasons are specific to selling something rather than general advice.
The paid path cannot wait on an inbox. A buyer arrives from a product page, goes out to the payment provider, and comes back to a dashboard that has to know who they are. Inserting "check your email, then come back" into that sequence puts a mail server in the middle of a checkout.
One auth email per action is already the budget. Supabase's built-in email sender has a low, fixed, project-wide ceiling shared across confirmation, recovery and magic-link sends — the failure mode and the reasoning are in email rate limit exceeded. Making every sign-in an email send spends that budget on the most frequent action in the app.
The one-time-link mechanism is already here, where it earns its keep. Password recovery is a magic link by another name:
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${SITE_URL}/reset-password`,
});
Same emailed token, same code exchange, same callback — used for the one flow where the user cannot authenticate any other way. For returning buyers, Google sign-in removes the password without adding an inbox round trip.
Magic link against the alternatives
| Magic link | Password | Google OAuth | 6-digit email code | |
|---|---|---|---|---|
| Credential to store | None | Hash | None | None |
| Works on a second device | Only via token_hash | Yes | Yes | Yes |
| Depends on email delivery | Every sign-in | Recovery only | No | Every sign-in |
| Sign-in taps | 1 + inbox | 2 | 2 | 1 + inbox + 6 digits |
| Phishable | Link forwarding | Credential reuse | Consent screen | Code relay |
| Extra setup | Email template | None | OAuth client + consent screen | Email template |
The honest summary: magic links are excellent for products people sign into occasionally from one device, and they are a tax on products people sign into constantly or mid-transaction.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
both auth code and code verifier should be non-empty | Link opened on a different browser or device | Switch the flow to token_hash + verifyOtp |
| Session is null right after the redirect | The code was exchanged in a Server Component render | Do the exchange in a Route Handler or Server Action |
Redirect lands on your site root, not next | emailRedirectTo is not in the allowed redirect list | Add it under Auth → URL Configuration |
| Link works in dev, 404s in production | SITE_URL still points at localhost | Derive it from the deployment, as siteUrl() does here |
| Token already used before the user clicked | A mail scanner or link preview fetched it first | Expect it; let the user request a new link |
| New accounts appear from a sign-in form | shouldCreateUser defaults to true | Pass false and handle unknown emails explicitly |
| Repeated requests return 429 | The project-wide auth email ceiling | Configure your own SMTP provider; see the rate-limit post |
The re-request interval and the link's lifetime are both project settings — read them in your own dashboard under Auth rather than trusting a number in any article, including this one.
Frequently asked questions
Is a magic link less secure than a password? It moves the risk rather than removing it. There is no password to reuse or leak, and anyone with access to the inbox is the account. Whether that is better depends on what you would have done about password reuse otherwise.
Can the same callback handle magic links and OAuth?
Yes — that is what the route above does. Both arrive as a ?code= to exchange, and the only difference is which call sent the user away.
Do magic links work with the email OTP code instead?
Yes. The link and the six-digit code are the same token presented differently; editing the Magic Link email template to emit {{ .Token }} gives you the code flow, verified with verifyOtp.
Should a storefront use them at all? For a sign-in that happens during a purchase, no. For an admin or dashboard product where sessions are long and logins are rare, they remove a password from your database and a reset flow from your backlog.
Templates in this post
ASoc Catalyst is an AI-and-automation agency site with industry case studies and a three-tier pricing table, ASoc Chain is a self-custody DeFi landing page built on a trustless-by-design pitch, and ASoc Cognition markets an AI consulting studio across services, strategy and industry blocks. Each one ships the marketing surface; the auth flow above is what you add behind it.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
