Supabase

Supabase Magic Link: The Production Checklist

A pre-launch checklist for Supabase magic link and email-OTP sign-in: the mailer cap, the redirect allow list, the PKCE email template, and the link-scanner problem that burns single-use tokens.

Mahdi Ben RhoumaMahdi Ben Rhouma
9 min read

Supabase's passwordless sign-in runs on one call, signInWithOtp(), and that call looks identical in development and in production. What changes is everything around it: the built-in mailer only delivers to the project's own team and is capped at a low hourly rate, the Site URL still points at localhost, and a redirectTo outside the configured allow list is quietly replaced by the Site URL rather than rejected with an error. None of this is a bug — it is the documented default, and it only shows up once a real user tries to sign in. The fix is a short, concrete checklist, not a rewrite of the sign-in call.

Why this only breaks in production#

A fresh hosted Supabase project ships with three defaults that a development loop rarely exercises: a mailer meant for exploring email templates rather than reaching real inboxes, a Site URL that still points at http://localhost:3000 with a redirect allow list that doesn't yet hold the production URLs, and a single-use link that a mail security scanner can consume before the real user clicks it. None of the three announce themselves as a bug. The mailer refuses any address outside the project's team, with a documented error; a redirect that is not on the allow list is quietly replaced by the Site URL rather than rejected; and a scanner-consumed link only fails once the real user finally clicks it, by which point the cause is invisible.

The checklist#

1. Replace the built-in mailer before anyone outside your team signs in#

Every Supabase project ships with a built-in SMTP server meant for exploring and testing email templates. Supabase's SMTP guide is explicit about its limits: unless a custom SMTP server is configured, the built-in service only delivers to addresses that belong to the project's organisation. Supabase's Auth server rejects any other address with the email_address_not_authorized code, documented as "emails can only be sent to members in your Supabase organization" until a custom provider is configured. The send rate is capped too: Supabase's own rate-limit defaults, published in its docs repository, put the built-in mailer at 2 messages per hour as of October 2026, and the SMTP guide adds that the figure "can change without notice." Configure a transactional provider — Resend, AWS SES, Postmark, Twilio SendGrid, ZeptoMail and Brevo are the ones Supabase's guide lists — under Authentication → Emails → SMTP Settings before the first real signup. If confirmation emails stop arriving even after that, the dedicated troubleshooting guide covers the other causes — a broken template link, missing SPF/DKIM, or a user who is already confirmed.

2. Protect the sending domain, not just the inbox#

Once custom SMTP is live, the same SMTP guide recommends treating the sending domain like any other production asset: configure SPF, DKIM and DMARC with the email provider, and use a dedicated subdomain for auth mail, separate from marketing mail. Separately, Supabase's production checklist warns that some SMTP providers rewrite links for click tracking, which can corrupt the confirmation URL Supabase generated — disable link tracking on the provider's side. Custom SMTP still ships with a conservative default, too: the SMTP guide puts it at 30 messages per hour, and the same production checklist phrases the same default as 30 new users per hour. Either way, raise rate_limit_email_sent ahead of a launch, not during one, through the Management API:

bash
# Get your access token from https://supabase.com/dashboard/account/tokens
export SUPABASE_ACCESS_TOKEN="your-access-token"
export PROJECT_REF="your-project-ref"
 
curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \
  -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "rate_limit_email_sent": 60
  }'

3. Move the Site URL and the redirect allow list off localhost#

The Site URL under Authentication → URL Configuration defaults to http://localhost:3000 and doubles as the fallback redirect whenever a sign-in call does not pass its own redirectTo. Change it to the production domain, then add every redirectTo value the app actually uses to the Redirect URLs list. Supabase Auth's own server code shows exactly what happens otherwise: the function that builds a magic-link email checks the requested redirect against the allow list — plus any URL on the same scheme, host and port as the Site URL itself — then falls back to the request's Referer header, and finally to the Site URL, when nothing validates — no error, just a different destination. The dedicated fix for that exact symptom walks through the allow-list configuration in the dashboard. If the sign-in call itself is not wired up yet, the session and middleware guide covers the rest of the request and response plumbing around it. One template detail is easy to miss: if the app passes its own redirectTo, the email template still has to swap {{ .SiteURL }} for {{ .RedirectTo }}, or the link always points at the Site URL regardless of what the code requested.

4. Scope preview-deployment wildcards to your own team#

Supabase's redirect allow list accepts wildcard patterns: * matches a run of characters that excludes . and /, ** matches across them too. For Vercel preview deployments, the pattern Supabase's own docs give is scoped to the team:

text
https://*-<team-or-account-slug>.vercel.app/**

A bare https://*.vercel.app/** also matches Vercel's preview domains, but it is not scoped to anything — it matches any customer's preview deployment, not just this team's. If previews already redirect to production instead of the preview URL itself, that is the exact symptom the Vercel preview SITE_URL fix covers.

5. Match the email template to the flow you actually use#

Supabase's magic link can run over the implicit flow or PKCE, and the email template has to match. For PKCE, the template sends a token hash rather than a ready-made session link:

html
<h2>Sign in to your account</h2>
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Sign in</a></p>

The same reminder from item 4 applies here: if the app passes its own redirectTo (a Vercel preview URL, for instance), swap {{ .SiteURL }} for {{ .RedirectTo }} in that href, or every PKCE link lands on the Site URL regardless of which preview requested it. The route behind that link needs a cookie-aware server client, not a plain one, or the session Supabase issues never reaches the browser:

ts
import { createServerClient } from '@supabase/ssr';
import { type NextRequest, NextResponse } from 'next/server';
 
export async function GET(request: NextRequest) {
  const tokenHash = request.nextUrl.searchParams.get('token_hash');
 
  if (tokenHash) {
    const response = NextResponse.redirect(new URL('/dashboard', request.url));
    const supabase = createServerClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL!,
      process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
      {
        cookies: {
          getAll: () => request.cookies.getAll(),
          setAll: (cookiesToSet, cacheHeaders) => {
            cookiesToSet.forEach(({ name, value, options }) =>
              response.cookies.set(name, value, options)
            );
            for (const [key, value] of Object.entries(cacheHeaders ?? {})) {
              response.headers.set(key, value);
            }
          },
        },
      }
    );
 
    const { error } = await supabase.auth.verifyOtp({
      token_hash: tokenHash,
      type: 'email',
    });
    if (!error) return response;
  }
 
  return NextResponse.redirect(new URL('/auth/error', request.url));
}

This GET route verifies on load, which is simple but not final — item 6 below covers why that's also the exact request a mail scanner would make, and what to change about it. Separately, the getAll/setAll pair above is what makes the session persist. A plain @supabase/supabase-js client here would still verify the token hash successfully and leave the browser signed out, because nothing would have written the session cookie onto the response. The second argument to setAll matters too: Supabase's own guide to this pattern says the cache headers it carries (Cache-Control, Expires, Pragma) must reach the response, or a CDN can cache this redirect and serve the next visitor someone else's session cookie.

One more template detail: an address that has not yet confirmed its account goes through Supabase's signup path first, which — unless autoconfirm is on — sends the Confirm signup template rather than the Magic Link one. Edit both templates to use {{ .TokenHash }}, or new users will land on a confirmation email that still points at the implicit-flow link.

Supabase's production checklist documents a failure mode that has nothing to do with configuration: in enterprise systems, email scanners may prefetch and open links in incoming mail to check them for safety, and because Supabase's magic links are single-use, that automated visit consumes the token before the real user clicks anything. The route built in item 5 is exactly what a scanner's prefetch would hit: a GET /auth/confirm?token_hash=… that calls verifyOtp() on load, consuming the token whether a person or a scanner made the request. Supabase's own recommendation is to replace the direct magic link in the template with a link to a page under your control that shows a manual "Sign in" button — for the standard implicit-flow template, that button redirects to the original magic-link URL once clicked; adapted to the token_hash route from item 5, the equivalent is a button whose click, not the page's load, is what triggers verifyOtp(). The recommendation assumes a scanner's automated visit fetches the page but does not click the button — Supabase's docs don't say more than that about how scanners behave. Point the failure path of that page at a clear "request a new link" action.

7. Leave the expiry alone — or lower it, never raise it#

The default expiry for a magic link or email OTP is one hour, and the same setting also governs password recovery, email change and invitation links. If users complain about expired links, raising the window is not the documented fix: Supabase's production checklist recommends keeping it at 3600 seconds or lower, and the passwordless guide itself says a duration above 86,400 seconds (one day) is strongly discouraged and can only be set through the Management API. A link that fails minutes after it was sent is not an expiry problem to extend — check the scanner cause above, or whether a resend (item 7) replaced the token the user is still clicking from an earlier email.

8. Decide shouldCreateUser on purpose, and don't trust one countdown for two causes#

signInWithOtp() creates a new account for an email it has not seen before, by default. Setting shouldCreateUser: false turns a typo'd address into a clean error instead of a silent new account — the right default for a sign-in form that sits next to a separate sign-up form.

The resend cooldown needs more care than a fixed countdown. Supabase's own magic-link handler checks the time since the last link was sent and, when it's inside the configured window, returns the request as over_email_send_rate_limit — the same code the hourly send cap from item 1 returns once it's exceeded. The code alone can't tell the two apart: a resend a few seconds too early, or a project that has used its whole hour. Rather than branch on that, start the sixty-second timer the moment a link actually sends, which is what opens the per-address window in the first place — and treat any over_email_send_rate_limit that still comes back once the timer runs out as the hourly cap, with a plain "too many requests, try again shortly" rather than restarting a clock that will not fix it:

tsx
'use client';
 
import { useEffect, useState } from 'react';
 
export function ResendButton({
  onResend,
  initialCooldownSeconds = 0,
}: { onResend: () => Promise<boolean>; initialCooldownSeconds?: number }) {
  // Seed from the original send, not just a successful resend — the
  // server's 60s window opened when the FIRST link went out, and a
  // button that starts at 0 lets an early click reach onResend while
  // that window is still running.
  const [secondsLeft, setSecondsLeft] = useState(initialCooldownSeconds);
 
  useEffect(() => {
    if (secondsLeft <= 0) return;
    const id = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
    return () => clearTimeout(id);
  }, [secondsLeft]);
 
  async function handleClick() {
    const sent = await onResend();
    if (sent) setSecondsLeft(60);
    // The button is disabled while secondsLeft > 0, so onResend only runs
    // once that window has elapsed; sent === false here means the hourly
    // cap, not the cooldown — show the generic message instead of
    // restarting the timer.
  }
 
  return (
    <button disabled={secondsLeft > 0} onClick={handleClick}>
      {secondsLeft > 0 ? `Resend in ${secondsLeft}s` : 'Resend code'}
    </button>
  );
}

Supabase's message for the per-address cooldown specifically is a template:

text
For security purposes, you can only request this after %d seconds.

with the %d filled in with the remaining wait at request time. The same handler reuses the recovery timer for this cooldown by design, rather than tracking magic-link sends on their own counter.

9. Add CAPTCHA and forward the real client IP#

Supabase's sign-in, sign-up and password-reset endpoints support CAPTCHA protection — worth turning on before any public announcement, since sign-up and OTP endpoints are a common target for abuse. Separately: the /auth/confirm route in item 5 calls verifyOtp(), and Supabase's rate-limit table scopes that endpoint by IP address — 360 requests per hour, with bursts up to 30. Because that route runs on the server, every request reaches Supabase from the server's own IP, not the visitor's, so one noisy deployment can exhaust the limit for every user behind it. Forwarding the real client IP with the Sb-Forwarded-For header restores per-visitor limits — but not with the anon-key client item 5 built: Supabase's own example for this passes a secret key in place of the publishable one, because publishable and legacy anon/service-role keys are not supported. That makes it a server-only client, built with the secret key, separate from the publishable key used everywhere else in the route. IP forwarding also has to be turned on first: it is off by default and must be explicitly enabled for new projects in the dashboard's rate-limit settings.

Verify before the announcement#

  • Request a magic link with an address outside the project's team, on the custom SMTP configuration, and confirm it arrives — not the built-in mailer's email_address_not_authorized.
  • Request a link, confirm the resend button starts its own sixty-second countdown from that successful send, and confirm a second request before the countdown ends never reaches the API at all.
  • Deploy a preview branch and confirm the link lands on the preview URL, not on production — a redirect that is not on the allow list has no error to catch it with, so this has to be checked by hand.
  • Re-read the Redirect URLs list once more after adding the preview wildcard — a mistyped pattern fails exactly like a missing one, with the same fallback to the Site URL.

The next error#

Once the checklist above is wired in, what remains are the errors a user interface needs to handle gracefully rather than configure away: over_email_send_rate_limit on a repeated request, and otp_expired on a link or code that outlived its window. Supabase Auth Error Codes: Fixes + TS Cheat Sheet covers the full set of codes with a typed handler, including both of these.

Most of the defaults above are not specific to magic links — the same mailer, allow list and rate limits apply to email/password sign-up and to password recovery. Magic links just surface the gaps earlier, because the sign-in itself fails the moment the email does not arrive. Work through the list once, and the production launch checklist covers what it does not touch — RLS, indexes, backups, and the rest of a Next.js and Supabase launch.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.