Magic Link Production Checklist
Technology

Magic Link Production Checklist

Run this checklist before you ship magic‑link auth to production. It covers configuration, RLS, monitoring, performance, and cost safeguards.

2026-06-01
6 min read
Magic Link Production Checklist

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:

  1. Verify that the Supabase auth.signInWithOtp call returns a JWT and that the JWT is stored securely on the client.
  2. Run the RLS audit to ensure no table can be accessed without a valid JWT.
  3. 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 session object – Run the client code locally and confirm the console prints a non‑null session.
  • [ ] Email delivery is logged – Check Supabase’s auth.email_sent table 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.refreshSession and verify a new sb-access-token cookie appears.

I usually verify the flow with a tiny script that mimics a real user:

bash
# 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
text
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 the SELECT policy includes auth.uid() IS NOT NULL.
  • [ ] No allow all policies 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_role and 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:

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:

bash
psql "postgres://postgres:YOUR_PASSWORD@localhost:5432/postgres" -f scripts/rls-audit.sql
text
 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-webhook endpoint.
  • [ ] Auth callback health check returns 200curl -f https://api.example.com/health/auth must succeed.
  • [ ] Metrics for auth.signInWithOtp latency are exported – Verify that your Prometheus scrape includes supabase_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):

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 })
  }
}
bash
curl -s -o /dev/null -w "%{http_code}" https://api.example.com/health/auth
text
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/otp request 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.verify call in a load test script.

Load‑test snippet (using k6) to verify JWT verification time:

js
// 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,
  })
}
bash
k6 run jwt-benchmark.js
text
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:

diff
--- 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:

bash
supabase db reset && supabase db push
text
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:

Frequently Asked Questions

|

Have more questions? Contact us

Written by

Mahdi Br
Mahdi Br

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 — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.