Magic links: allow-listed redirect, same OTP type on verify, no ghosts

shouldCreateUser: false stops ghost users; links only land on allow-listed URLs; a token_hash must be verified with the type it was minted for.

RLS & Auth

· Chapter

9

·

3

min read

The answer. A magic link is a one-time token wrapped in a URL. Three settings decide whether the flow works. shouldCreateUser: false on signInWithOtp stops a mistyped address from silently creating a new, unusable account. The redirect target must be the Site URL or one of the Redirect URLs (wildcards like https://app.example.com/** are allowed) or Auth refuses to send the user there. And the verify step must name the same type the token was issued for: magiclink, signup, recovery, email_change are one mechanism with a tag. Two ways to build it: the built-in flow ({{ .ConfirmationURL }} in the template, Auth verifies and redirects with a code your client exchanges) or your own (template or admin.generateLink gives you a token_hash, you build the URL and send the mail, your callback calls verifyOtp). We took the second route so tenant-branded emails go through our own mailer.

The pattern.

// request
await supabase.auth.signInWithOtp({ email, options: { shouldCreateUser: false, emailRedirectTo: `${origin}/auth/callback` } });

// callback route for a token_hash link (PKCE-safe, works without JS on the landing page)
const { error } = await supabase.auth.verifyOtp({ token_hash, type: "magiclink" });   // same type as minted
return error ? redirect("/error?type=invalid_link") : redirect("/app");
Email template for your own URL:  {{ .SiteURL }}/auth/callback?token_hash={{ .TokenHash }}&type=magiclink

Watch out.

  • The callback must not sit behind your auth middleware; a guarded page bounces the request before the code or hash is ever exchanged.
  • One link per minute per address and one hour of validity are the defaults; the UI needs a "sent, check your inbox" state that does not retry.
  • Users created by the admin API are inactive until they complete one OTP link; the activation mail proves the mailbox exists.

Related: Disable public signups; mint users server-side with app_metadata · SSR: one server client per request, and let it write the cookies · Auth hooks are instance config, not rows — bind them per project