Put the production origin on every allow-list
Set the Supabase Site URL to your production URL and add each real callback URL, including preview domains if you use them, to Redirect URLs. In Firebase, add the domain under Authentication settings, Authorized domains, and remove localhost from the production project.
Stop scanners from using up one-time links
Have the user type a one-time code instead of clicking a token link, or send them to a page on your site with a button that submits the token only when a person clicks it. Supabase documents both options.
const { data, error } = await supabase.auth.verifyOtp({
email,
token, // the code the user typed
type: 'email',
});
Authenticate the sending domain
Gmail requires every sender to have SPF or DKIM, and bulk senders to have SPF, DKIM and DMARC. Set up all three with your email provider, and don't send production mail through a provider's built-in test mailer. Supabase's default service only delivers to pre-authorized addresses and is rate limited.
dig +short TXT example.com | grep spf1
dig +short TXT <selector>._domainkey.example.com
dig +short TXT _dmarc.example.com
Set cookie attributes deliberately
Decide whether the session cookie should be host-only or shared across subdomains, and set Domain to match. Use Secure and HttpOnly. Use SameSite=None; Secure only when you need cross-site requests. Mark responses that set auth cookies as uncacheable so a CDN does not store them.
Set-Cookie: session=...; Path=/; Secure; HttpOnly; SameSite=Lax
Cache-Control: private, no-store
Make token refresh single-flight
Within one page, share one in-flight refresh promise. Across tabs of the same origin, use the Web Locks API so only one tab rotates the token at a time. On the server, refresh in one place (in Next.js with Supabase, the middleware or proxy) and verify with getClaims() rather than trusting getSession().
let refreshing: Promise<Session> | null = null;
export function refreshOnce(): Promise<Session> {
refreshing ??= navigator.locks
.request('auth-refresh', () => doRefresh())
.finally(() => { refreshing = null; });
return refreshing;
}
Tie paid access to a stable user ID
Store access against the auth provider's user ID, not the email address. With Stripe Payment Links, append client_reference_id=<user id> to the link. Stripe returns it on the checkout.session.completed webhook, so you can grant access to the right account.
https://buy.stripe.com/<link>?client_reference_id=<user_id>
Log every auth failure with a reason
Log each sign-in, refresh and access-check failure with a specific reason, the route, and the user ID when you have it. Never log passwords, access tokens or session IDs. If you need to follow one session across requests, log a hash of its ID.
{"event":"auth_failure","reason":"refresh_token_already_used","route":"/api/me","user_id":"u_123","session_hash":"9f2c..."}