Secure OAuth, magic links, and MFA in Supabase + Next.js comes down to four architectural commitments: PKCE for every browser-initiated flow (the default in @supabase/ssr) [1], a strict server-side redirect URL allowlist that blocks open-redirect abuse [2], magic links read by exchangeCodeForSession exactly once at a server-side callback route [3], and MFA enforced by Row Level Security policies that read the JWT's aal claim rather than UX-only gating [6]. Get those four right and the entire passwordless auth surface is one piece of architecture, not three loose features.
This is the cluster 1.6 pillar in the Authentication & Access Control macro. It covers the three Supabase Auth methods that share PKCE and cookie semantics but have different failure modes: OAuth (Google, GitHub, Apple), magic links (email one-time tokens), and TOTP-based MFA. The cluster 1.1 pillar on Supabase Auth in Next.js App Router covers the broader server-side architecture. This post drills into the three sub-flows where most implementation mistakes happen.
TL;DR:
- PKCE is the default and the only flow you should use.
@supabase/ssruses PKCE for OAuth and magic links automatically [1]. The auth code lives at most 5 minutes and exchanges exactly once. Implicit-flow examples that put tokens in URL fragments are deprecated and unsafe. - Redirect URL allowlists block open-redirect attacks. Every
redirectTovalue must match the Redirect URLs configured in the Supabase dashboard [2]. Use exact paths in production.**wildcards are for local dev only. - Magic links are one-time use, server-rate-limited, and configurable. Default expiry is one hour, default rate limit is one request per 60 seconds per user [3]. The token is invalidated on first use.
- MFA enforcement belongs in RLS, not the UI. Add
restrictivepolicies on sensitive tables that requireauth.jwt() ->> 'aal' = 'aal2'[6]. UI checks block honest users; RLS blocks attackers who skip the UI. - The 5 failure modes, in order of frequency: trusting the
nextquery param without origin validation, wildcards in the production redirect allowlist, swallowing the PKCE code exchange error, treatingaal1andaal2as interchangeable in authorization checks, and exposing MFA enrollment without verifying the original session.
Table of contents
- Why does Supabase's auth surface need a secure-implementation pillar?
- How does Supabase OAuth with PKCE actually work in Next.js?
- How do you make magic links replay-resistant?
- How do you implement MFA enrollment and AAL2 step-up?
- How do you enforce AAL2 in RLS and Server Actions?
- What are the 5 most common implementation failure modes?
- What does this mean for your auth architecture?
Why does Supabase's auth surface need a secure-implementation pillar?
The three methods share a server-side machinery (cookies via @supabase/ssr, JWT issued by GoTrue, PKCE flow by default) but each has its own surface for mistakes. OAuth fails when the redirect URL allowlist is too permissive or the next query parameter passes user input straight into a server-side redirect. Magic links fail when the developer wires them up to the client-side verifyOtp flow rather than the server-side exchangeCodeForSession route, or when the rate limit is left at the default and an attacker enumerates accounts. MFA fails when the UI hides the option but the database layer doesn't enforce it, or when the AAL claim is read but never checked against the requested operation.
The common shape of every mistake here is a check that exists in one layer and not the other. The OAuth callback validates origin but the next parameter passes through unverified. The UI shows the MFA challenge but the Server Action behind the next button doesn't re-check aal. The magic link template uses PKCE but the developer pastes the code into a client-side signInWithOtp call that runs the legacy implicit flow. This is OWASP A07 Identification and Authentication Failures territory [7]; the failure pattern is structural, not procedural.
The architectural fix is to make the auth check live at the lowest layer possible: RLS for data access, Server Actions for mutations, the callback route for OAuth/magic-link exchange. UI-only gating is decoration; it cannot defend against an attacker who skips the UI.
How does Supabase OAuth with PKCE actually work in Next.js?
PKCE (Proof Key for Code Exchange) is the OAuth 2.1 default flow for any client that runs in a browser, including Next.js App Router apps [1]. The flow has four steps:
- The client generates a random
code_verifier(high-entropy string) and acode_challenge(SHA-256 hash of the verifier). - The browser redirects to the OAuth provider with
code_challengeandcode_challenge_method=S256in the URL. - The provider authenticates the user and redirects back to your app with an
authorization_code(and the same state value). - Your server exchanges the code for a session by sending the code along with the original
code_verifier. The auth server hashes the verifier, compares to the stored challenge, and only issues the session if they match.
The verifier never appears in a URL or referer header. An attacker who only observes the authorization code (via log capture, referer leak, or shoulder-surfing the URL bar) cannot complete the exchange without the verifier, which lives in cookie storage on the originating client. The authorization code itself has a 5-minute validity and can only be exchanged once [1].
In @supabase/ssr, this happens automatically. The client-side call to supabase.auth.signInWithOAuth generates the verifier and stores it in a cookie. The provider redirects to a callback route on your server. The callback calls exchangeCodeForSession, which retrieves the verifier from the cookie and posts it to Supabase Auth along with the code. Your job is to wire the callback correctly.
The route handler at app/auth/callback/route.ts looks like this:
import { createServerClientWithCookies } from '@/lib/supabase/server'
import { NextResponse, type NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/dashboard'
if (code) {
const supabase = await createServerClientWithCookies()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
return NextResponse.redirect(`${origin}/login?error=auth_callback_error`)
}
Two details matter here. First, the redirect targets ${origin}${next}. The origin is derived from the inbound request and cannot be spoofed by an attacker. If next had been read from an Origin header instead, an attacker could craft a request with a malicious header and bounce the user to an external domain.
Second, next is currently used unconditionally. That is failure mode #1 below. A safe handler validates next.startsWith('/') and rejects values starting with // or \\ (which browsers may interpret as protocol-relative URLs):
function isSafeNext(next: string | null): string {
if (!next) return '/dashboard'
if (!next.startsWith('/')) return '/dashboard'
if (next.startsWith('//') || next.startsWith('/\\')) return '/dashboard'
return next
}
The Server Action that initiates OAuth lives in actions/auth.ts:
'use server'
import { createServerClientWithCookies } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export async function loginWithGoogle(redirectTo?: string) {
const supabase = await createServerClientWithCookies()
const next = redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//')
? redirectTo
: '/dashboard'
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback?next=${encodeURIComponent(next)}`,
},
})
if (error) return { error: error.message }
if (data.url) redirect(data.url)
}
The redirectTo value sent to Supabase Auth must match the Redirect URLs allowlist in the dashboard [2]. The allowlist is the single defense against redirect_uri substitution. Without it, an attacker could initiate the OAuth flow with their own redirectTo and intercept the resulting auth code at a domain they control.
Configure the allowlist with the principle of least privilege:
- Production: exact paths only.
https://yourdomain.com/auth/callbackandhttps://yourdomain.com/auth/confirm. Neverhttps://yourdomain.com/**. - Preview deployments on Vercel:
https://*-<account-slug>.vercel.app/**if you need preview-URL auth. - Local dev:
http://localhost:3000/**is acceptable because it cannot be reached from outside your machine.
Stripping the allowlist down to exact paths in production is the single most impactful hardening step in the OAuth surface, because it prevents the redirect-URI-substitution vector that drives most of the 2025 disclosed OAuth open-redirect CVEs [8].
How do you make magic links replay-resistant?
Magic links are one-time email tokens that exchange for a session when clicked. The Supabase documentation is explicit: "Magic Links only work with email addresses and are one-time use only" [3]. The token is invalidated server-side on first successful exchange, so even if the email leaks (forwarded inbox, archived chat thread, malicious browser extension), the window of exposure ends the moment the legitimate user clicks the link.
The default parameters are conservative but configurable:
- Expiry: 1 hour by default. Configurable in the dashboard under Authentication > Email Templates.
- Rate limit: 1 request per 60 seconds per user by default. Configurable under Authentication > Rate Limits.
- Flow: PKCE-based when the email template sends a token hash (the
token_hashtemplate variable) [3].
The wrong way to integrate magic links into Next.js App Router is to send the user directly to a client-side page that calls verifyOtp from a Client Component. That path bypasses the server-side cookie write that @supabase/ssr wants, and it makes the verification visible in the URL fragment where a malicious browser extension could intercept it.
The right way is identical in shape to the OAuth callback: a server-side route that calls exchangeCodeForSession. Configure the email template to send a {{ .ConfirmationURL }} that points at your server route:
Click here to log in: {{ .ConfirmationURL }}
And the route at app/auth/confirm/route.ts:
import { createServerClientWithCookies } from '@/lib/supabase/server'
import { NextResponse, type NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url)
const tokenHash = searchParams.get('token_hash')
const type = searchParams.get('type')
const next = searchParams.get('next') ?? '/dashboard'
if (tokenHash && type === 'magiclink') {
const supabase = await createServerClientWithCookies()
const { error } = await supabase.auth.verifyOtp({
type: 'magiclink',
token_hash: tokenHash,
})
if (!error) {
const safeNext = next.startsWith('/') && !next.startsWith('//') ? next : '/dashboard'
return NextResponse.redirect(`${origin}${safeNext}`)
}
}
return NextResponse.redirect(`${origin}/login?error=invalid_magic_link`)
}
Three properties matter:
- The token hash is read from the URL exactly once and verified server-side. The same path cannot be replayed because Supabase Auth invalidates the token on success.
- The cookie write happens on the server, so the session is
httpOnlyfrom the first moment of its existence. The JWT and session management pillar covers the cookie semantics in depth. - The redirect target is constrained the same way as the OAuth callback. The
nextparameter is validated to start with/and not be protocol-relative.
The Server Action that sends the magic link runs the same rate-limited pattern as signInWithPassword:
export async function sendMagicLink(email: string, redirectTo?: string) {
const { success } = await rateLimit('magic-link', 3, 60)
if (!success) {
return { error: 'Too many requests. Try again in a minute.' }
}
const parsed = emailSchema.safeParse({ email })
if (!parsed.success) {
return { error: parsed.error.errors[0].message }
}
const supabase = await createServerClientWithCookies()
const next = redirectTo?.startsWith('/') && !redirectTo.startsWith('//') ? redirectTo : '/dashboard'
const { error } = await supabase.auth.signInWithOtp({
email: parsed.data.email,
options: {
emailRedirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/confirm?next=${encodeURIComponent(next)}`,
shouldCreateUser: true,
},
})
if (error) return { error: error.message }
return { success: 'Check your email for the magic link.' }
}
The application-layer rate limiter on top of Supabase's server-side one is intentional. Supabase's default protects the auth server. The application rate limit protects against email-flooding (sending dozens of links to a known address to bury legitimate ones) and against probing attempts to discover whether an account exists. The rate-limiting guide covers the in-memory and Redis-backed patterns.
How do you implement MFA enrollment and AAL2 step-up?
Supabase MFA supports two factor types: TOTP (an authenticator app generating six-digit codes) and Phone (SMS or WhatsApp) [4]. TOTP is the default recommendation; SMS has known interception risks at the carrier layer and should only be used as a fallback for users who cannot manage an authenticator app.
The enrollment flow has three calls:
'use client'
import { createBrowserClient } from '@supabase/ssr'
export async function enrollTOTP() {
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// Step 1: enroll returns a QR code (SVG) and a secret.
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'totp',
friendlyName: 'Authenticator app',
})
if (error) throw error
// Show data.totp.qr_code (SVG) and data.totp.secret to the user.
// They scan the QR or paste the secret into their authenticator app.
return { factorId: data.id, qrCode: data.totp.qr_code, secret: data.totp.secret }
}
export async function verifyTOTP(factorId: string, code: string) {
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// Step 2: prepare a challenge.
const { data: challenge, error: challengeError } = await supabase.auth.mfa.challenge({
factorId,
})
if (challengeError) throw challengeError
// Step 3: verify the user's code against the challenge.
const { error: verifyError } = await supabase.auth.mfa.verify({
factorId,
challengeId: challenge.id,
code,
})
if (verifyError) throw verifyError
}
The browser client is appropriate here because enrollment is an interactive flow that operates on the currently-authenticated user. The session cookie carries the existing AAL1 access token; the verify call promotes the session to AAL2 atomically. The Supabase docs are explicit: "Upon verifying a factor, all other sessions are logged out and the current session's authenticator level is promoted to aal2" [5].
Step-up authentication is the reverse direction: an already-enrolled user logs in fresh (AAL1) and needs to step up to AAL2 to access a protected route. The state check is getAuthenticatorAssuranceLevel:
const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
if (data?.currentLevel === 'aal1' && data?.nextLevel === 'aal2') {
// User has MFA enrolled but has not completed it for this session.
// Redirect to the step-up challenge page.
redirect('/auth/mfa-challenge')
}
The four combinations of currentLevel and nextLevel map to four distinct UX states:
| currentLevel | nextLevel | Meaning | Action |
|---|---|---|---|
aal1 | aal1 | No MFA enrolled | Optional enrollment prompt |
aal1 | aal2 | MFA enrolled, not verified this session | Force step-up to AAL2 |
aal2 | aal2 | MFA verified this session | Allow sensitive actions |
aal2 | aal1 | MFA was disabled after the JWT was issued | Force re-login |
The aal2 -> aal1 row is the subtle one. It indicates the user's JWT was issued at AAL2 but the underlying factor has since been removed (admin unenrolled them, or they unenrolled mid-session from a different device). The right response is to force a re-login so the new JWT reflects current state.
How do you enforce AAL2 in RLS and Server Actions?
UI-only gating is decoration. An attacker who skips the UI by calling the Supabase REST API or a Server Action directly bypasses the check entirely. The architectural defense is to enforce AAL2 at the database layer with Row Level Security policies that read the JWT's aal claim, and at the Server Action layer with explicit getClaims() checks.
The RLS pattern from the Supabase blog on MFA-via-RLS [6]:
-- Restrictive policy: in addition to other read policies, the user must be AAL2.
CREATE POLICY "Require MFA for admin reads"
ON admin_operations
AS restrictive
TO authenticated
USING ((SELECT auth.jwt() ->> 'aal') = 'aal2');
CREATE POLICY "Require MFA for admin writes"
ON admin_operations
AS restrictive
TO authenticated
WITH CHECK ((SELECT auth.jwt() ->> 'aal') = 'aal2');
The restrictive keyword is important. By default RLS policies are permissive, meaning they OR together: if any policy grants access, access is granted. A restrictive policy AND-s with everything else: the read must pass both the existing identity policy and the AAL2 requirement. This is the layer that makes MFA architectural rather than procedural. The existing user-id-based policies still scope rows to the right user; the restrictive policy adds the MFA gate on top.
For tables that aren't fully MFA-gated but have specific sensitive operations (rotating an API key, downloading export data, deleting an account), gate the operation in the Server Action instead:
'use server'
import { createServerClientWithCookies } from '@/lib/supabase/server'
export async function rotateApiKey(formData: FormData) {
const supabase = await createServerClientWithCookies()
const { data: claims, error: claimsError } = await supabase.auth.getClaims()
if (claimsError || !claims) {
return { error: 'Not authenticated' }
}
if (claims.claims.aal !== 'aal2') {
return { error: 'MFA required for this operation', requireMfa: true }
}
// Proceed with the privileged operation.
// ...
}
The getClaims() call validates the JWT signature locally against the cached JWKS and returns the parsed claims [1.4 JWT pillar]. Reading claims.claims.aal after a successful validation is cryptographically equivalent to having Supabase Auth confirm the AAL2 state, without the network round trip. The JWT and session management pillar covers the getClaims vs getUser vs getSession decision tree in detail.
The combination of RLS restrictive policy plus Server Action aal check is defense in depth. If the Server Action forgets the check, the database denies the row. If the database policy is missed during a migration, the Server Action still rejects the call. Both layers exist because each has a different failure mode: developer-forgets-the-line for Server Actions, migration-script-bug for RLS. The multi-tenancy and RBAC pillar covers the same defense-in-depth pattern for tenant scoping.
What are the 5 most common implementation failure modes?
These five account for the overwhelming majority of "I implemented MFA but..." and "OAuth works but..." support questions. They appear in roughly this frequency order across audits.
Failure 1: Trusting the next query parameter without origin validation
The OAuth callback redirects the user to whatever path next contains. If next is /dashboard, this is fine. If next is //evil.com/phish, the browser may interpret the leading double-slash as a protocol-relative URL and bounce the user to an attacker domain that imitates your login screen.
The fix is the isSafeNext validator earlier in this post: reject any next that doesn't start with /, and reject any next that starts with // or /\. The project's loginWithGoogle action does this inline:
const next = redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//')
? redirectTo
: '/dashboard'
This is OWASP A01 Broken Access Control via open redirect [8]. The OAuth flow itself is fine; the post-flow redirect is what gets weaponized. Recent disclosures (CVE-2026-5467 in Casdoor, the Onlook web application open-redirect via X-Forwarded-Host) show the same pattern: trusted callback handler, user-controlled redirect tail, attacker substitutes the destination.
Failure 2: Wildcards in the production redirect URL allowlist
Supabase's redirect URL allowlist supports glob patterns like ** (matches any sequence including separators) [2]. These are convenient for local dev (http://localhost:3000/**) and for Vercel preview URLs. They are dangerous in production.
A production entry like https://yourdomain.com/** accepts any path under your domain as a valid redirect target. If your app has any open redirect bug elsewhere (an admin redirect helper, a deprecated callback route, a marketing-link bouncer), an attacker can combine the OAuth flow with that internal open redirect to bounce auth codes through your domain to theirs.
The Supabase docs are explicit on this: "While the 'globstar' (**) is useful for local development and preview URLs, we recommend setting the exact redirect URL path for your site URL in production" [2]. The fix is mechanical: audit the allowlist, replace ** entries with exact paths, leave wildcards only for ephemeral preview deployments.
Failure 3: Swallowing the PKCE code exchange error
The classic shape:
// BAD
if (code) {
const supabase = await createServerClientWithCookies()
await supabase.auth.exchangeCodeForSession(code).catch(() => {})
return NextResponse.redirect(`${origin}${next}`)
}
The .catch(() => {}) silently absorbs the error and the user is redirected to ${next} regardless of whether the code exchange succeeded. The route ends up redirecting unauthenticated users to /dashboard, where the middleware/proxy.ts check either redirects them to login (creating a confusing loop) or, worse, lets them in if the route protection has a bug.
This is OWASP A10 Mishandling of Exceptional Conditions [7]: the catch block fails open. The fix is to inspect the result explicitly:
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (error) {
return NextResponse.redirect(`${origin}/login?error=auth_callback_error`)
}
return NextResponse.redirect(`${origin}${safeNext}`)
Fail closed. The CSRF/XSS/SQL injection guide covers the same fail-closed principle for server-side error handling.
Failure 4: Treating aal1 and aal2 as interchangeable
You add an admin route and gate it with if (!user) redirect('/login'). The MFA enrollment screen lives behind that gate. An attacker who phishes a password (or uses a stolen one from a credential dump) lands on the admin route at aal1, hits the enrollment screen, and now controls the MFA factor too. The admin's actual TOTP secret never matters because the attacker enrolled their own first.
The fix is to gate sensitive routes (including MFA management itself) on aal === 'aal2', not just on "user is authenticated." For the MFA enrollment screen specifically, the fix is to require a recent password re-confirmation before enrolling a factor:
// Before showing the enrollment screen, force password re-entry.
// Only proceed if the user authenticated within the last N minutes
// AND, if AAL2 was previously achieved, the current session is AAL2.
The structural fix in the application: every Server Action that handles MFA enrollment, factor deletion, or recovery code regeneration must call getClaims() and check both that the user is authenticated and that the session is at the expected AAL. Don't reuse the same handler for enroll and unenroll without inspecting the current AAL state.
Failure 5: Exposing MFA enrollment without verifying the session
The most subtle of the five. The enrollment flow calls supabase.auth.mfa.enroll, which requires an authenticated session. If your code uses the browser client (with the user's existing cookie session), this is correct. If your code accidentally uses the admin client (with service_role), you've granted enrollment-on-behalf-of-anyone to anyone who can hit your endpoint.
The shape that goes wrong:
'use server'
import { createAdminClient } from '@/lib/supabase/admin'
export async function enrollMfa(userId: string) {
const admin = createAdminClient()
// BUG: admin client can enroll factors for ANY user, identified by parameter.
// If userId comes from the form, an attacker can enroll their factor on the admin's account.
return admin.auth.admin.mfa.enroll({ userId, factorType: 'totp' })
}
The service_role admin API exists for legitimate admin tooling, but the user ID parameter must come from the validated session, not the request payload. The backend-only data access pattern and the multi-tenancy guide both name the same root cause: trusting a user identifier from the request when the validated session already contains the answer.
The fix:
'use server'
import { createServerClientWithCookies } from '@/lib/supabase/server'
export async function enrollMfa() {
const supabase = await createServerClientWithCookies()
// The session cookie identifies the user. No userId parameter from the client.
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'totp',
friendlyName: 'Authenticator app',
})
if (error) return { error: error.message }
return { factorId: data.id, qrCode: data.totp.qr_code }
}
The session is the source of truth for identity. Any time a userId parameter shows up in a Server Action signature, treat it as a smell and trace it back to the form.
What does this mean for your auth architecture?
The three Supabase Auth methods (OAuth, magic links, MFA) ship secure defaults: PKCE, one-time tokens, asymmetric JWT signing keys (per the JWT pillar). The implementation mistakes that ship security holes are almost always in the seams: the redirect after OAuth, the route that handles the magic-link click, the Server Action that gates a sensitive operation. Each seam is a layer where a check either exists or doesn't.
The architectural commitments that close those seams:
- OAuth callback validates
nextand matches the dashboard allowlist. No//-prefixed paths, no**wildcards in production. Failure 1 and Failure 2. - Magic links and OAuth share the same callback shape. Server-side route,
exchangeCodeForSession(orverifyOtpfor thetoken_hashvariant), fail-closed on error. Failure 3. - MFA is enforced at the database layer with restrictive RLS policies. UI gating is for UX; the database is for security. Failure 4.
- Identity comes from the validated session, never from a request parameter. The
service_roleadmin client never receives user-IDs from forms. Failure 5.
These four commitments line up with the architectural baseline this template ships: server-side cookies via @supabase/ssr, getClaims() for local JWT validation, restrictive RLS policies for AAL2 gating, and the same redirectTo validator across login, signup, password reset, and OAuth callbacks. The 12-step hardening checklist covers the broader posture; the pre-launch security audit names auth-failure verification as Check 1 of 12. The secure SaaS launch checklist treats auth and session validation as the first of the seven non-negotiables before launch.
For inspecting JWT claims and AAL state during development, the JWT decoder tool parses any Supabase access token and shows the aal, amr, and session_id claims live. The SaaS security checklist tool covers the broader auth-and-session checks alongside the rest of the pre-launch posture.
If your codebase started as an AI-generated prototype, the OAuth callback is one of the first places to audit for the open-redirect pattern, and the MFA enrollment screen is often missing entirely. The vibe-coded migration playbook walks the audit-to-harden sequence; the auth-failure category from the OWASP Top 10 pillar maps each failure mode above to the OWASP 2025 A07 line item.
This is the auth surface SecureStartKit ships with by default: PKCE on every browser-initiated flow, server-side callback routes with next validation, strict redirect URL allowlists, and MFA-aware RLS policies on every admin-scoped table. The site you're reading is the demo of that architecture; the same patterns live in the template you would download.
Frequently Asked Questions
- Do I need PKCE if I use Supabase Auth in Next.js?
- Yes. The Supabase JS client and `@supabase/ssr` use the PKCE flow by default for OAuth and magic links, and it is the only flow that is safe for browser-initiated auth. PKCE adds a one-time code verifier that the client generates locally, hashes, and sends with the authorization request. When the auth code comes back, the server must present the original verifier to exchange it for a session. An attacker who intercepts only the URL with the code (via referer leak, log capture, or browser history) cannot complete the exchange without the verifier. Older implicit-flow examples that send tokens directly in the URL hash are no longer recommended and have been deprecated by the OAuth 2.1 spec.
- Why does my Supabase OAuth login redirect to the wrong site after the provider auth?
- Your `redirectTo` value is not in the Supabase Auth allowlist, so Supabase falls back to the project's Site URL. The fix is in the Supabase dashboard under Authentication > URL Configuration: add every callback URL your app uses (production, preview deployments, localhost) to the Redirect URLs allowlist. Use exact paths in production and glob patterns like `http://localhost:3000/**` only for local development. Never allow `**` against your production domain without an explicit path constraint, because that pattern accepts arbitrary paths under your domain and turns the OAuth callback into an open redirect primitive that attackers can use for phishing.
- Are Supabase magic links single-use?
- Yes. The Supabase documentation states that magic links are one-time use only. Once a user clicks the link and the token is exchanged for a session, the token is invalidated and cannot be reused. Token expiry is configurable in the dashboard and defaults to one hour. Rate limits are also configurable: by default a user can only request one magic link per period (60 seconds in standard projects). The single-use property is enforced server-side at the Supabase Auth layer, so even if a token leaks through email forwarding or logs, the window for abuse is limited and ends the moment the legitimate user logs in.
- What is the difference between AAL1 and AAL2 in Supabase MFA?
- AAL stands for Authenticator Assurance Level. AAL1 means the user has authenticated with one factor: password, OAuth, or magic link. AAL2 means the user has additionally completed a second factor, typically a TOTP code from an authenticator app. The level is stamped into the JWT as the `aal` claim, which means Row Level Security policies and Server Actions can both read it to gate sensitive operations. The `getAuthenticatorAssuranceLevel()` API returns the current level and the next level the user is eligible for; if `currentLevel` is `aal1` and `nextLevel` is `aal2`, the user has MFA enrolled but has not completed the second factor for this session and should be prompted to step up.
- How do you enforce that admins must use MFA in a Supabase + Next.js app?
- Three layers, all required. First, enroll the admin's TOTP factor via `supabase.auth.mfa.enroll`, then verify with `challenge` and `verify` calls before the factor becomes active. Second, write Server Action authorization checks that read `getClaims()`, inspect the `aal` claim, and reject the request when an admin operation is attempted at `aal1`. Third, write RLS policies on admin-only tables with `using ((select auth.jwt() ->> 'aal') = 'aal2')` so even a direct database call from a Server Action that forgot the check still fails closed. The database-layer policy is what turns MFA from a UX choice into an architectural guarantee.
Built for developers who care about security
SecureStartKit ships with these patterns out of the box.
Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.
References
- PKCE flow, Supabase Auth Documentation— supabase.com
- Redirect URLs, Supabase Auth Documentation— supabase.com
- Passwordless email logins, Supabase Auth Documentation— supabase.com
- Multi-Factor Authentication, Supabase Auth Documentation— supabase.com
- MFA (TOTP), Supabase Auth Documentation— supabase.com
- Multi-factor Authentication via Row Level Security Enforcement— supabase.com
- OWASP Top 10:2025— owasp.org
- Open Redirect, OWASP Foundation— owasp.org
Related Posts
Supabase JWT + Session Management in Next.js [2026]
Supabase JWT lifecycle, ES256 asymmetric signing keys, httpOnly cookie storage, and getClaims vs getUser vs getSession for Next.js apps.
Pre-Launch Security Audit: 12 Checks That Matter Most [2026]
Pre-launch security audit for Next.js + Supabase: 12 highest-impact checks of 30, in audit order, with triage rules. Run weeks before launch.
Supabase Multi-Tenancy + RBAC: The Secure Pattern [2026]
Multi-tenancy and RBAC in Supabase + Next.js. Tenant scoping via JWT claims + RLS, the composite index rule, and five cross-tenant leak modes.