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