Magic Link Production Checklist
Run this checklist before you ship magic‑link auth to production. It covers configuration, RLS, monitoring, performance, and cost safeguards.
TL;DR#
Before you ship magic‑link authentication, walk this checklist top to bottom. It catches the five categories of failure that have actually shipped to production in the projects this site documents.
If you only have time for the highest‑use items, do these three:
- Verify that the Supabase
auth.signInWithOtpcall returns a JWT and that the JWT is stored securely on the client. - Run the RLS audit to ensure no table can be accessed without a valid JWT.
- Enable email delivery logs and a health‑check endpoint for the auth callback.
Authentication#
Magic‑link authentication is a three‑step dance: request an OTP, deliver the email, and exchange the OTP for a JWT. Each step has a concrete artifact you can test.
- [ ] OTP request returns a
sessionobject – Run the client code locally and confirm the console prints a non‑nullsession. - [ ] Email delivery is logged – Check Supabase’s
auth.email_senttable for a row matching the request email. - [ ] JWT is stored in an HttpOnly cookie – Inspect the browser’s cookie store after the callback; the cookie name should be
sb-access-token. - [ ] Refresh token rotation works – After 30 minutes, call
auth.refreshSessionand verify a newsb-access-tokencookie appears.
I usually verify the flow with a tiny script that mimics a real user:
# Install the Supabase JS client
npm i @supabase/supabase-js
# Run a one‑off script that requests an OTP and prints the session
node - <<'EOS'
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'https://YOUR-PROJECT.supabase.co',
process.env.SUPABASE_ANON_KEY
)
async function run() {
const { data, error } = await supabase.auth.signInWithOtp({
email: 'test@example.com',
options: { emailRedirectTo: 'http://localhost:3000/auth/callback' },
})
if (error) console.error('OTP request failed:', error)
else console.log('OTP request succeeded, session:', data.session)
}
run()
EOS
OTP request succeeded, session: {
access_token: "...",
refresh_token: "...",
expires_at: 1718000000,
token_type: "bearer"
}
If the script prints a session object, you’ve passed the first two checklist items. Next, open the browser dev tools after clicking the link in the email and verify the sb-access-token cookie is present and flagged HttpOnly.
Why it matters: An unauthenticated user who can bypass the OTP step can obtain a valid session token, opening the door to data theft. The cookie flag prevents JavaScript from reading the token, mitigating XSS risk.
Database#
Supabase’s power comes from Row‑Level Security. A mis‑configured policy can expose every row to anyone who knows a table name.
- [ ] All public tables have
auth.uid()checks – Open the Supabase dashboard, navigate to each table, and confirm theSELECTpolicy includesauth.uid() IS NOT NULL. - [ ] No
allow allpolicies exist – Run the policy audit query (see below) and ensure the result set is empty. - [ ] Service role key is not used in client code – Search the repo for
service_roleand verify it only appears in server‑only files. - [ ] RLS is enabled on every table that stores user data – In the dashboard, each table’s “RLS” toggle must be on.
Here’s the audit query I keep in scripts/rls-audit.sql:
-- scripts/rls-audit.sql
SELECT
table_schema,
table_name,
policy_name,
definition
FROM
information_schema.policies
WHERE
is_enabled = 'YES'
AND definition NOT ILIKE '%auth.uid()%';
Run it locally:
psql "postgres://postgres:YOUR_PASSWORD@localhost:5432/postgres" -f scripts/rls-audit.sql
table_schema | table_name | policy_name | definition
--------------+------------+-------------+-----------------------------------------
public | profiles | select_self| (auth.uid() = user_id)
public | orders | select_self| (auth.uid() = customer_id)
(2 rows)
If any row appears without auth.uid() in the definition, you have a policy gap—fix it before you ship.
Why it matters: RLS is the last line of defense against privilege escalation. A single missing check can let a malicious user enumerate all users’ data.
Observability#
Without logs you won’t know whether the magic‑link flow is breaking in the wild.
- [ ] Email delivery webhook is configured – In Supabase → Settings → Email, the “Webhook URL” points to your
/api/auth/email-webhookendpoint. - [ ] Auth callback health check returns 200 –
curl -f https://api.example.com/health/authmust succeed. - [ ] Metrics for
auth.signInWithOtplatency are exported – Verify that your Prometheus scrape includessupabase_auth_otp_duration_seconds. - [ ] Error alerts are wired to Slack – Test by forcing a bad OTP request and confirming a Slack message appears.
Example of a health‑check endpoint in Next.js (pages/api/health/auth.ts):
// pages/api/health/auth.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { supabaseAdmin } from '@/lib/supabaseAdmin'
export default async function handler(_: NextApiRequest, res: NextApiResponse) {
try {
// A cheap call that requires a valid JWT
const { error } = await supabaseAdmin.auth.getUser()
if (error) throw error
res.status(200).json({ status: 'ok' })
} catch (e) {
res.status(500).json({ status: 'error', error: (e as Error).message })
}
}
curl -s -o /dev/null -w "%{http_code}" https://api.example.com/health/auth
200
If the endpoint returns anything other than 200, fix the underlying auth client or server configuration before release.
Performance#
Magic‑link flows involve network calls to Supabase and email providers. Latency spikes can degrade user experience.
- [ ] OTP request latency < 500 ms – Measure with Chrome DevTools Network tab; the
POST /auth/v1/otprequest should complete under half a second. - [ ] Email provider response time < 2 s – Check the webhook logs for the time between Supabase sending the email and your webhook receiving the delivery status.
- [ ] JWT verification on the server < 5 ms – Benchmark the
jwt.verifycall in a load test script.
Load‑test snippet (using k6) to verify JWT verification time:
// k6 script: jwt-benchmark.js
import http from 'k6/http'
import { check } from 'k6'
export default function () {
const res = http.post('https://api.example.com/auth/verify', {
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
check(res, {
'status is 200': (r) => r.status === 200,
'verification <5ms': (r) => r.timings.waiting < 5,
})
}
k6 run jwt-benchmark.js
running (00m30.00s), 10/10 iterations, 100% complete, 0.00% skipped
checks.........................: 100.00% ✓ 20 passed
http_req_duration..............: avg=4.2ms min=3.8ms max=5.0ms
If the average verification time exceeds 5 ms, consider moving JWT verification to a Cloudflare Worker or enabling Supabase Edge Functions for faster response.
Cost & quotas#
Supabase’s free tier is generous, but magic‑link email volume can quickly exceed limits.
- [ ] Monthly email count < 5 % of your plan quota – In the Supabase dashboard, the “Email Sent” metric should be well under the quota.
- [ ] Auth request rate < 100 req/s – Use the Supabase dashboard’s “Auth Requests” chart to confirm you’re not hitting the rate limit.
- [ ] RLS policy scans are indexed – Verify that columns used in
auth.uid()checks have indexes; otherwise each request can cause a full table scan, inflating compute costs.
Here’s a diff that adds an index to the profiles.user_id column, which is commonly used in RLS policies:
--- a/migrations/20240601_add_user_id_index.sql
+++ b/migrations/20240601_add_user_id_index.sql
@@ -1,4 +1,7 @@
CREATE INDEX IF NOT EXISTS idx_profiles_user_id
-ON public.profiles (user_id);
+ON public.profiles (user_id);
+
+-- Ensure the index is used by RLS policies
+ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
Run the migration:
supabase db reset && supabase db push
Applying migration 20240601_add_user_id_index.sql...
✅ Migration applied
If your email count is approaching the quota, set up a daily alert in Supabase or switch to a dedicated transactional email provider.
Stop conditions#
Do not ship if any of these is true:
- RLS audit returns a table without
auth.uid()checks. - OTP request latency exceeds 500 ms in a production‑like environment.
- Health‑check endpoint returns a non‑200 status code.
These aren’t preferences — each one has historically caused a production incident on a project this site documents.
Companion content#
If you fail an item on the checklist, the deep‑dive lives here:
- OTP flow verification → Supabase Authentication with Next.js 15 Complete Guide
- RLS policy audit → RLS Audit Checklist One Page: Supabase Problem -> Fix
- Advanced email handling → Advanced Authentication Patterns with Next.js and Supabase
Related#
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Continue Reading
I Used Supabase Auth in Production for a Year. Here Are 11 Things I Wish I Knew.
One year, 50K users, and a surprising number of 2 a.m. pages. Here is what I would tell my past self before pushing Supabase Auth to production.
7 Next.js + Supabase Architecture Decisions I'd Make Differently
After shipping multiple production apps with Next.js and Supabase, here are the decisions that cost the most time to undo — and what I'd do instead from day one.
7 Things I Wish I Knew Before Scaling Next.js + Supabase to 100K Users
Hard lessons from taking a Next.js and Supabase app from MVP to production scale. The mistakes that cost us hours, the patterns that saved us, and what I would do differently.
Browse by Topic
Find stories that matter to you.
