Skip to main content
ASoc
Tutorial

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.

The ASoc Team8 min read

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

  1. Your form calls signInWithOtp({ email, options: { emailRedirectTo } }).
  2. Supabase Auth generates a one-time token and emails the link.
  3. The user clicks it; Supabase verifies the token and redirects to your callback with a ?code=.
  4. Your callback calls exchangeCodeForSession(code) and the auth cookies get set on the response.
  5. 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:

  • next goes through safeNext. 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 unvalidated next is 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=auth with no detail. "This link expired" versus "no such account" is an account-enumeration difference, and the same reasoning governs the sign-in action in src/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.

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 linkPasswordGoogle OAuth6-digit email code
Credential to storeNoneHashNoneNone
Works on a second deviceOnly via token_hashYesYesYes
Depends on email deliveryEvery sign-inRecovery onlyNoEvery sign-in
Sign-in taps1 + inbox221 + inbox + 6 digits
PhishableLink forwardingCredential reuseConsent screenCode relay
Extra setupEmail templateNoneOAuth client + consent screenEmail 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

SymptomCauseFix
both auth code and code verifier should be non-emptyLink opened on a different browser or deviceSwitch the flow to token_hash + verifyOtp
Session is null right after the redirectThe code was exchanged in a Server Component renderDo the exchange in a Route Handler or Server Action
Redirect lands on your site root, not nextemailRedirectTo is not in the allowed redirect listAdd it under Auth → URL Configuration
Link works in dev, 404s in productionSITE_URL still points at localhostDerive it from the deployment, as siteUrl() does here
Token already used before the user clickedA mail scanner or link preview fetched it firstExpect it; let the user request a new link
New accounts appear from a sign-in formshouldCreateUser defaults to truePass false and handle unknown emails explicitly
Repeated requests return 429The project-wide auth email ceilingConfigure 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.

Keep reading

Tutorial9 min read

Supabase MCP in Cursor: Your RLS Policies Do Not Apply To It

The connection runs SQL as `postgres`, which carries rolbypassrls. Measured against six RLS-enabled tables, plus the migration drift it surfaced immediately.

Read more
Tutorial11 min read

Supabase Migrations: Eight Files, and Four of Them Are Fixes

An audit of this storefront's eight commerce migrations — three create, five change or fix — plus the three habits that make one safe to re-run.

Read more