All playbooks
Stack setup
22 min read

Magic link auth with Supabase, no foot guns

The exact magic-link auth flow I run on both the admin and client portals of sarmalinux.com, with Resend as the email transport, server actions for sign-in, cookie sessions, callback handling, and RLS hardening. Plus the four mistakes I made first so you do not have to.

Passwords are a liability. They leak in breaches, they get reused, they need a reset flow, they need a complexity policy, they need rate limiting, they need a captcha eventually. None of that work makes your product better. It is pure tax.

Magic links move the entire credential burden onto the email provider. The user proves they own an inbox, you mint a session, done. No password table, no reset flow, no breach surface beyond the email account itself. For a studio site with two portals (admin for me, client for the people I work with), it is the right call. Nobody wants yet another password to remember for the four times a year they check an invoice.

The objection is usually latency. The user has to context switch to their inbox. In practice, with Resend hitting Gmail and Outlook inboxes in under two seconds, it is fine. The first sign-in feels novel, every subsequent one is just a click.

Resend vs the default SMTP

Supabase ships with a built-in email sender. Do not use it in production. It is rate limited (a handful of emails per hour on the free tier), it sends from a noreply.supabase.co address that lands in spam, and the template is generic. The whole point of magic link is that the email arrives quickly and looks like it came from you.

Resend is the right choice for a studio site. It costs nothing up to 3,000 emails per month, the deliverability is excellent, and it integrates with Supabase as a plain SMTP provider. The dashboard steps:

  1. In Resend, add and verify your domain (three DNS records: SPF, DKIM, and a return-path CNAME). Wait for "Verified" to go green.
  2. Create an API key scoped to "Sending access". Copy it.
  3. In Supabase, go to Project Settings, Authentication, SMTP Settings. Enable custom SMTP. Host smtp.resend.com, port 465, user resend, password is the API key.
  4. Sender email auth@yourdomain.com, sender name your brand. Save.
  5. Go to Authentication, URL Configuration. Set the Site URL to your production domain and add every redirect URL (local dev, preview deploys, both subdomains) to the allow list.

Send a test from the Authentication panel. If it lands in your inbox in under two seconds with your sender, you are done. If it lands in spam, your DKIM is not propagated yet, wait an hour.

Auth is not a feature you build, it is a contract you sign. Sign it with the smallest possible surface area and let the boring providers carry the risk.

The sign-in server action

One form, one server action, one function call. No client-side Supabase JS for the sign-in step, because there is no reason to ship the auth client to the browser when a server action does the job in half the bytes.

ts
// app/actions/sign-in.ts 'use server' import { createServerClient } from '@supabase/ssr' import { cookies, headers } from 'next/headers' const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ export async function signIn(formData: FormData) { const email = String(formData.get('email') ?? '').trim().toLowerCase() if (!EMAIL_RE.test(email)) { return { ok: false, error: 'That does not look like a valid email.' } } const cookieStore = await cookies() const hdrs = await headers() const origin = hdrs.get('origin') ?? 'https://sarmalinux.com' const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll: () => cookieStore.getAll(), setAll: (all) => all.forEach(({ name, value, options }) => cookieStore.set(name, value, options)), }, }, ) const { error } = await supabase.auth.signInWithOtp({ email, options: { emailRedirectTo: `${origin}/auth/callback`, shouldCreateUser: true, }, }) if (error) return { ok: false, error: error.message } return { ok: true } }

The form on the sign-in page is a plain HTML form bound to this action. The page itself stays static. The action returns a serialisable object so the page can show "Check your inbox" without a client round trip.

The callback route

When the user clicks the magic link in their email, Supabase redirects them to /auth/callback on your domain with a token_hash and a type in the query string. This is the only place that mints a session.

ts
// app/auth/callback/route.ts import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' import { NextResponse, type NextRequest } from 'next/server' import type { EmailOtpType } from '@supabase/supabase-js' export async function GET(request: NextRequest) { const url = new URL(request.url) const token_hash = url.searchParams.get('token_hash') const type = url.searchParams.get('type') as EmailOtpType | null const next = url.searchParams.get('next') ?? '/client' if (!token_hash || !type) { return NextResponse.redirect(new URL('/sign-in?error=missing_token', url.origin)) } const cookieStore = await cookies() const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll: () => cookieStore.getAll(), setAll: (all) => all.forEach(({ name, value, options }) => cookieStore.set(name, value, options)), }, }, ) const { error } = await supabase.auth.verifyOtp({ token_hash, type }) if (error) { return NextResponse.redirect(new URL(`/sign-in?error=${encodeURIComponent(error.message)}`, url.origin)) } return NextResponse.redirect(new URL(next, url.origin)) }

The cookie helpers in @supabase/ssr handle the session cookie write inside verifyOtp. You do not set any cookies yourself, and you do not see the JWT. That is the whole point of the SSR helpers; they keep the secret material out of your code.

Reading the session

Every server component that needs the user goes through the same util. One file, one function, no exceptions.

ts
// lib/supabase/server.ts import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' export async function getSupabase() { const cookieStore = await cookies() return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll: () => cookieStore.getAll(), setAll: (all) => all.forEach(({ name, value, options }) => cookieStore.set(name, value, options)), }, }, ) } export async function getUser() { const supabase = await getSupabase() const { data: { user } } = await supabase.auth.getUser() return user }

Always getUser(), never getSession(). The difference matters. getSession() reads the cookie and trusts the JWT claims without contacting Supabase, which means a tampered cookie returns a user object that looks valid. getUser() hits the Supabase auth server and validates the token. The latency is single-digit milliseconds; the safety is enormous.

Protecting pages

Two layers. Middleware does the cheap rejection (no cookie, no session, redirect to /sign-in) so unauthenticated requests never even reach the page handler. The page itself does the expensive check (getUser()) and any role-specific gating, because middleware cannot safely call out to Supabase on every request without burning your auth-server budget.

ts
// middleware.ts import { createServerClient } from '@supabase/ssr' import { NextResponse, type NextRequest } from 'next/server' export async function middleware(request: NextRequest) { const response = NextResponse.next({ request }) const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll: () => request.cookies.getAll(), setAll: (all) => all.forEach(({ name, value, options }) => response.cookies.set(name, value, options)), }, }, ) const { data: { user } } = await supabase.auth.getUser() const isProtected = request.nextUrl.pathname.startsWith('/admin') || request.nextUrl.pathname.startsWith('/client') if (isProtected && !user) { const url = request.nextUrl.clone() url.pathname = '/sign-in' url.searchParams.set('next', request.nextUrl.pathname) return NextResponse.redirect(url) } return response } export const config = { matcher: ['/admin/:path*', '/client/:path*'], }

The page-level check is then a one-liner inside the layout for /admin or /client, which also handles role gating against a profiles table keyed by user id.

RLS, the real lock

Auth in the application code is just the front door. The real lock on your data is Row Level Security. Without it, anyone who gets your anon key (and they will, it ships to the browser) can read every row in every table. With it, the anon key is harmless.

The discipline: enable RLS on every table in the public schema, write a deny-all baseline policy, then add explicit allow policies that key off auth.uid().

sql
-- For every table in public, default to locked. alter table public.invoices enable row level security; -- Baseline: deny everything. This is implicit when RLS is on with no -- matching policies, but writing it explicitly documents the intent. create policy "deny all by default" on public.invoices for all using (false); -- Explicit allow: a client can read invoices addressed to them. create policy "clients read their own invoices" on public.invoices for select using (client_user_id = auth.uid()); -- Service role bypasses RLS entirely. Use the service key only on the -- server, never expose it to the browser.

Test it by opening the SQL editor in Supabase, switching the role to authenticated, setting a fake auth.uid, and running a select. If you see rows you should not, your policy is wrong. Repeat for insert, update, and delete. RLS that you have not tested is not RLS, it is hope.

The branded email template

The default Supabase email looks like an automated receipt from 2011. Replace it. In the dashboard, Authentication, Email Templates, Magic Link. The body accepts HTML and a few mustache variables, the main one being {{ .ConfirmationURL }}.

The template I use, mobile-safe (single-column, inlined styles, no web fonts, tested in Gmail, Outlook, Apple Mail, and Yahoo):

html
<!DOCTYPE html> <html> <body style="margin:0;padding:0;background:#0a0a0a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"> <tr> <td align="center" style="padding:40px 20px;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;background:#141414;border-radius:12px;border:1px solid #262626;"> <tr> <td style="padding:32px 32px 16px 32px;"> <div style="font-size:22px;font-weight:800;color:#fafafa;letter-spacing:-0.02em;">SarmaLinux</div> </td> </tr> <tr> <td style="padding:0 32px 8px 32px;"> <h1 style="margin:0;font-size:20px;font-weight:600;color:#fafafa;">Sign in to your account</h1> </td> </tr> <tr> <td style="padding:8px 32px 24px 32px;"> <p style="margin:0;color:#a3a3a3;font-size:15px;line-height:1.55;"> Click the button below to sign in. This link is good for one hour and can only be used once. </p> </td> </tr> <tr> <td align="center" style="padding:8px 32px 32px 32px;"> <a href="{{ .ConfirmationURL }}" style="display:inline-block;padding:14px 28px;background:#fafafa;color:#0a0a0a;text-decoration:none;font-weight:600;border-radius:8px;font-size:15px;"> Sign in </a> </td> </tr> <tr> <td style="padding:0 32px 32px 32px;border-top:1px solid #262626;"> <p style="margin:24px 0 0 0;color:#737373;font-size:13px;line-height:1.5;"> If you did not request this, ignore the email. Nothing will happen. </p> </td> </tr> </table> </td> </tr> </table> </body> </html>

Two notes. Use a table layout, not flexbox, because Outlook still renders mail with Word's HTML engine. Keep total weight under 102 KB or Gmail clips the message and your button disappears.

Pitfalls

Using the anon key where the service role is required

The anon key respects RLS, the service role bypasses it. Listing all users, sending admin notifications, or reading other peoples rows needs the service role. Keep that key on the server only, never in a public env var, and gate every server action that uses it behind your own auth check.

Forgetting the redirect URL allow list

Supabase will silently refuse to redirect anywhere outside the Site URL plus the allow list. Add localhost, the production domain, and both subdomains (admin and client). If sign-in works locally but breaks in production, this is almost always the cause.

Trusting getSession() on the server

getSession() reads the cookie and decodes the JWT without validating it against Supabase. A user with a tampered cookie returns a session object that looks fine. Always use getUser() on the server, which contacts the auth server and validates the token. The extra few ms are worth it.

Confusing token_hash with code

The PKCE OAuth flow uses a code parameter and exchangeCodeForSession. The email OTP flow uses token_hash plus type and verifyOtp. They look similar in the URL and produce identical errors when mixed up. For magic link, it is always token_hash and verifyOtp.

Hitting the 200 row cap on listUsers

supabase.auth.admin.listUsers() returns a maximum of 200 users per call by default and silently truncates. If you build an admin user table without paginating, you will lose users once you cross 200 and not notice until someone complains. Always paginate, or query the auth.users view directly with the service role.

Wrap up

Magic link with Supabase, Resend, and the SSR helpers is roughly two hundred lines of code in total: one server action, one callback route, one middleware, one session util, and a handful of SQL policies. Set it up once, test it properly, and the auth conversation is closed for the life of the product. No password reset flow to maintain, no breach surface to worry about, no cron job to rotate sessions, no support tickets about lockouts. Time better spent on the actual product.

Want this done for you?

If you would rather skip the YAK shave and have someone who has done this fifty times set it up properly, that is what I do for a living.

Start a project