The Supabase password reset flow breaks in five specific ways, and every one of them is invisible until someone exploits it. The recovery token leaks via the Referer header to third-party scripts on the change-password page. The recovery session persists in cookies after the user abandons the flow, creating an unattended authenticated session. The reset token gets reused on double-click or back-button replay. The OTP window is configured wide enough for brute-force attacks. The Server Action returns different responses for "email found" vs "email not found", leaking which addresses are registered users. Each has a one-pass fix.
The Supabase Auth pillar covers the sign-in, sign-up, session, and OAuth callback flows. This post is the one Supabase Auth piece that pillar deliberately doesn't try to fit: the password reset flow has its own threat model (account takeover via recovery, not session theft), its own state machine (the PASSWORD_RECOVERY event creates a real session before the user has proved they know the new password), and its own rate-limit surface. The Information Gain is the five-failure-mode catalog tied to verified primary sources (CWE-640, the Supabase docs on the PKCE password recovery flow, MDN's Referrer-Policy spec) plus the architectural fix for each, anchored to the Server Action SecureStartKit ships today.
TL;DR:
- The Referer header leaks recovery tokens. Any third-party script loaded on the change-password page (analytics, error tracker, A/B testing widget) receives the full URL including the token. PKCE flow is the primary fix;
Referrer-Policy: no-referreron the change-password route is the belt-and-braces backup. - The recovery session is a real session. Supabase emits
PASSWORD_RECOVERYand signs the user in. If they abandon the tab, the session sits in cookies. Sign the user out afterupdateUsersucceeds, and refuse to redirect to/dashboardfrom/auth/callbackwhen the redirect target is the change-password page. - The reset token can be reused if the flow is implicit. PKCE's
exchangeCodeForSessionconsumes the code; the implicit fragment can be replayed until expiry. CVE-2024-5277 in the CWE-640 reference catalog is exactly this pattern [1]. - The OTP window has to be tight. Supabase's default Auth OTP expiry is 1 hour; CWE-640 cites CVE-2024-38287 (8-digit reset values brute-forced) [1]. If you swap the URL token for a 6-digit email OTP for convenience, you're inside the brute-force window. Drop the expiry to 10 minutes; layer a per-IP rate limit on top of the per-user default Supabase enforces [3].
- The reset response leaks email existence. Most implementations return "user not found" vs "check your email" differential messages. OWASP A07 (Identification and Authentication Failures) catalogs this as one of the recovery-flow vulnerabilities. Return the success message unconditionally; catch and silently log the rate-limit and server errors.
Table of contents
- Why is the Supabase password reset flow so easy to get wrong?
- How does the Supabase password reset flow actually work end-to-end?
- What are the 5 password reset failure modes and how do you fix each?
- How do you build the change-password page securely?
- Why we wire the reset through /auth/callback, not the implicit fragment
- When the password reset flow is a sign you need passkeys
Why is the Supabase password reset flow so easy to get wrong?
Password recovery is the one auth flow where the threat model inverts. Sign-in protects against an attacker who doesn't have the credentials. Password recovery has to protect against an attacker who is trying to gain the credentials by impersonating the legitimate user's email. The whole flow runs on the assumption that the recovery channel (the email inbox) belongs to the right person, and every misstep in the implementation pushes more of that assumption onto the application.
CWE-640 frames the category precisely: "The product contains a mechanism for users to recover or change their passwords without knowing the original password, but the mechanism is weak" [1]. The three documented consequences are unauthorized access, denial of service via brute force on legitimate user IDs, and security bypass [1]. Two of the listed examples are direct password reset CVEs: CVE-2024-5277 (token not invalidated after use, reuse for account takeover) and CVE-2024-38287 (8-digit random reset values, brute-forceable) [1]. Both are implementation failures, not algorithm failures.
Supabase ships sensible defaults for the request side. resetPasswordForEmail sends a single-use link tied to the user record, the per-user rate limit blocks rapid recovery requests, and the PKCE flow strips the token from the post-click URL. The failure surface is what happens AFTER the user clicks the link: which session the recovery code creates, where the application redirects to, whether the change-password page leaks the URL via Referer, and how the application phrases the response when an attacker probes for valid emails. None of that is in Supabase's stack to fix. The application owns all of it.
How does the Supabase password reset flow actually work end-to-end?
The Supabase password reset flow has two phases (request and update) and two transport modes (implicit fragment and PKCE code exchange). The Supabase docs document both modes step by step: the implicit flow sends a link that drops the user on a "change password page" with the access token in the URL fragment, while the PKCE flow sends a link to an /auth/confirm endpoint that does a server-side code exchange before redirecting [2]. The change-password page itself, per the docs, "should be accessible only to authenticated users" [2]. The recovery code creates that authentication.
In implicit mode, the flow is:
- The app calls
supabase.auth.resetPasswordForEmail(email, { redirectTo }). - Supabase sends an email with a link of the form
https://your-app.com/<redirectTo>#access_token=<jwt>&refresh_token=<jwt>&type=recovery. - The user clicks the link. The browser navigates to
redirectTo. The Supabase client (initialized on the change-password page) reads the fragment, exchanges the tokens for a session, and emits thePASSWORD_RECOVERYevent viaonAuthStateChange. - The application listens for
PASSWORD_RECOVERY, shows the new-password form, and callssupabase.auth.updateUser({ password })on submit. The Supabase docs are explicit: "In order to use theupdateUser()method, the user needs to be signed in first." The recovery event is what creates that signed-in state. - On success, Supabase emits
USER_UPDATED.
In PKCE mode, the flow is:
- Same
resetPasswordForEmailcall, but the email template is updated to point at/auth/confirmwith atoken_hashquery parameter [2]. - The user clicks the link. The browser hits
/auth/confirmon the server. - The server-side handler calls
supabase.auth.exchangeCodeForSession(code)(or the equivalent token-hash verify), which consumes the code, sets the session cookies, and 302-redirects to the change-password URL. - The change-password page reads the session from cookies (no fragment, no token in the URL the user lands on), shows the new-password form, and calls
updateUser.
The PKCE mode is the safer default for three independent reasons. The token never sits in the URL that the browser exposes to scripts on the change-password page (Failure 1 below). The code is single-use by the server-side exchange (Failure 3 below). And the change-password URL is clean enough to copy-paste, screenshot, or analytics-track without leaking the recovery credential.
What are the 5 password reset failure modes and how do you fix each?
The five symptoms below cover the password reset failure surface for a typical Supabase + Next.js application. Each is a documented attack class or a behavior the Supabase docs explicitly point at. Each has a one-pass fix.
Failure 1: Recovery token leaks via the Referer header
Signature: The change-password page is the URL Supabase's email link drops the user on. If the implicit flow is used, the URL contains #access_token=... in the fragment; if the email template wasn't updated for PKCE, the URL might contain ?token_hash=... in the query string. Either way, the browser holds the full URL while the page is open.
Root cause: Any third-party request the change-password page makes (a Google Tag Manager script, a Sentry error report, an LogRocket session replay, an A/B testing widget) carries a Referer header. The default browser policy since November 2020 is strict-origin-when-cross-origin, which sends only the origin (not the path or query) on cross-origin requests, and the full URL with path and query on same-origin requests [4]. Same-origin scripts therefore receive the full URL including the token. Cross-origin scripts loaded on HTTPS-to-HTTPS targets receive only the origin, which is safe, but a misconfigured policy or a downgrade to HTTP (HTTPS->HTTP) can change that. Fragments are not in the Referer header by spec, so the implicit fragment is safer than the PKCE query for this specific attack, but only safer, not safe: the fragment is still in window.location.hash and any client-side script on the page can read it.
Fix:
- Use PKCE flow. The
/auth/confirmhandler consumes the token server-side, sets the session cookies, and 302-redirects to the change-password URL with no token in the destination URL. The browser's address bar holds a clean URL; the Referer header carries the clean URL; same-origin and cross-origin scripts both receive a token-free URL. - Set
Referrer-Policy: no-referreron the change-password route. MDN documentsno-referreras "The Referer header will be omitted: sent requests do not include any referrer information" [4]. This is defense-in-depth in case PKCE is misconfigured. - Audit the third-party scripts loaded on the change-password page. Even with PKCE, a session replay tool that captures
document.cookieorlocalStoragewill record the active session. Remove any non-essential third-party scripts from/reset-passwordand/settings/password.
Failure 2: Session fixation post-recovery (the abandoned recovery session)
Signature: The user clicks the reset email link, lands on the change-password page, gets distracted, and closes the tab without entering a new password. The next time they open the application, they're signed in as themselves, having proved nothing other than that they read an email.
Root cause: The Supabase recovery flow signs the user in. The PKCE code exchange sets sb-<project>-auth-token cookies; the implicit flow's fragment-token exchange does the same. Once that session is in cookies, every server-rendered page treats the request as authenticated. The Supabase docs phrase the requirement explicitly: the change-password page is "accessible only to authenticated users" [2], which is true precisely because the recovery flow creates the authentication. The implementation gap is that nothing forces the user to actually complete the update before the session is treated as fully valid.
The abandoned-recovery-session problem stacks two attacker scenarios. First: an attacker who briefly accesses the user's inbox (a shared computer, a phished session, a parked email tab) can click the recovery link, gain a session, and then use that session to enumerate the user's data, change the email address on the account, or enroll an MFA factor (Failure 5 of the OAuth/MFA pillar catalogs the MFA enrollment surface specifically). Second: a user who legitimately requested the reset but didn't complete it leaves a persistent session that survives the original threat model.
Fix:
- After
updateUsersucceeds, callsupabase.auth.signOut({ scope: 'global' }). This invalidates every session for the user across all devices, forcing a fresh login with the new password everywhere. The session-cookie rotation here is the same pattern used post-password-change in any well-behaved auth system, and it's the only way to be sure the recovery session can't be replayed. - Treat the recovery session as a one-purpose session. The handler at
/auth/callbackalready gates the redirect target withnext.startsWith('/'). Add a check that, whennextis the change-password page, the only acceptable next action isupdateUser. Practically: keep the change-password page as a dedicated route, don't link to/dashboardfrom its layout, and add a server-side check that refuses any other Server Action when the session'saalis below the expected level. - Use a short OTP expiry. The shorter the window, the smaller the abandoned-session attack surface. Drop the Supabase Auth OTP expiry to 10 minutes in the dashboard; CWE-640's brute-force consequence applies here too [1].
Failure 3: Reset token reuse
Signature: The user double-clicks the email link, or the link is loaded twice by a prefetcher, or the URL ends up in a shared link preview and gets fetched by Slack's link unfurler. The first request succeeds. The second request also succeeds, leaving an authenticated session that the user never initiated.
Root cause: A recovery token has to be single-use to be safe. CWE-640's documented examples include CVE-2024-5277 verbatim: a "password recovery mechanism...does not invalidate the reset password token after it is used, allowing attackers to reuse the token to change passwords of victims" [1]. The Supabase implicit flow sends an access token + refresh token in the URL fragment. The access token is valid until its exp claim, and any party that intercepts it (the link preview bot, the email-scanning antivirus, the browser history sync on a shared device) can exchange it for a session within that window. The PKCE flow's exchangeCodeForSession consumes the code in a single server-side call, so the second attempted exchange fails.
Fix:
- Use PKCE flow. This single design choice resolves Failure 1 (Referer leak) AND Failure 3 (token reuse) on the same handler. The Supabase docs document the PKCE password reset flow explicitly: update the email template to point at the
/auth/confirmtoken-exchange endpoint [2]. - After a successful
updateUser, sign the user out globally. Even if a stale recovery session was created from an intercepted token, the global sign-out rotates the refresh token and forces every device to re-authenticate. - Configure short token lifetimes in the Supabase dashboard. The default 1-hour Auth OTP expiry is generous for the legitimate "user clicks the email within the hour" case but uncomfortable for the "link preview bot scrapes the URL within seconds" case. Ten minutes is enough for legitimate use and tight enough to close most automated-fetcher windows.
Failure 4: Weak password recovery token window (OWASP A07 / CWE-640)
Signature: The application uses Supabase's email OTP option (6-digit code instead of URL token) for convenience. An attacker requests a recovery code for a known user, then tries the 1,000,000 possible codes against the verify endpoint.
Root cause: CWE-640's CVE-2024-38287 example is exactly this pattern: an application "resets passwords to random 8-digit values, allowing brute force attacks" [1]. Six-digit OTPs have a brute-force search space of 1,000,000, which is small enough that a per-IP rate limit needs to be tight and the OTP expiry needs to be short. Supabase's default rate-limit table is "Limited By: Last request of the user" for password reset requests [3], meaning the per-user request rate is capped but the per-IP verify rate is not bounded by that specific entry. An attacker who can rotate IPs (any residential proxy network) can drive verify attempts faster than the per-user request rate limit suggests.
Fix:
- Keep the URL token, don't switch to the 6-digit OTP for the recovery flow. The URL token's search space is the full token entropy (Supabase's default is 32+ bytes), which is brute-force-impossible. The 6-digit OTP is the trade-off that creates the window.
- If you have business reasons to use the 6-digit OTP (SMS recovery, accessibility for users who can't open URLs), set the OTP expiry to the shortest value the use case allows. The Supabase dashboard exposes this in the Auth settings.
- Layer an application-level per-IP rate limit on the verify endpoint, on top of Supabase's per-user request limit [3]. The Server Action rate limiting guide walks the pattern. The brand-frame rule we apply at SecureStartKit is: every authentication-related Server Action has its own rate-limit key, distinct from the global app rate limit.
- Don't expose a "validate this OTP without consuming it" endpoint. The verify must consume the code on success and increment a failed-attempt counter on failure. Three failed attempts should burn the token; the user has to request a fresh one.
Failure 5: Email enumeration via differential response
Signature: The user types an email and clicks "Send reset link." The application returns "User not found" if the email isn't registered, or "Check your email for the reset link" if it is. An attacker now has a working email-existence oracle.
Root cause: OWASP A07 (Identification and Authentication Failures) catalogs differential responses as one of the credential-recovery anti-patterns. Most implementations leak the existence check either via the response message (the obvious failure) or via response timing (the recovery email send takes 200ms while the "user not found" return takes 5ms). For account-takeover attackers, knowing which emails belong to active users narrows the target list by orders of magnitude; for spam-and-phishing campaigns, it provides a clean list of known active recipients.
The Supabase resetPasswordForEmail is, by design, agnostic about whether the email exists. It returns no error when the email isn't registered. The leak almost always comes from the application's error-handling layer wrapping the call: a Server Action that returns { error: error.message } on any Supabase error message will, in some edge cases (rate-limit errors, project-disabled-state errors), expose information the docs intend to hide. A timing-based leak is even more subtle: a poorly-implemented "send email" path that does an extra database lookup for registered emails creates a response-time difference an attacker can measure with a few hundred samples.
Fix:
- Return the success message unconditionally. Catch any error from
resetPasswordForEmail, log it server-side, and return the same "Check your email for the reset link" response. The legitimate user who typed a typo finds out only when no email arrives; the attacker probing for valid emails finds out nothing. - Catch rate-limit errors as a separate response. "Too many attempts. Please try again later." is a legitimate user-facing message; it's an information-channel signal but not an email-existence signal (rate limits hit by-user, but the attacker can't tell whether the user exists without the rate limit being hit).
- Layer per-IP rate limiting on the request endpoint. Even without a content-based oracle, an attacker who can submit thousands of "test if this email exists" requests per minute will hit the per-user "Last request of the user" rate limit [3] one user at a time and infer registration from response timing patterns. The per-IP layer caps the attack rate.
- Equalize response timing. Wrap the success and error branches in an artificial minimum response time (say, 250ms). This kills the timing-based oracle that the content-based fix alone can't address.
How do you build the change-password page securely?
The change-password page is the route the email link drops the recovery session on. It exists to do exactly one thing: collect a new password and call updateUser on a session that's already been signed in by the recovery flow. The smallest secure version of this page wires four pieces together: a Server Action with Zod-validated input, a session check that confirms the request is authenticated (the recovery flow set the cookies; we just verify), a Referrer-Policy: no-referrer header on the route, and a global sign-out call on success.
The Server Action looks like this:
// actions/auth.ts
'use server'
import { z } from 'zod'
import { createServerClientWithCookies } from '@/lib/supabase/server'
import { rateLimit } from '@/lib/rate-limit'
const updatePasswordSchema = z.object({
password: z.string().min(12, 'Password must be at least 12 characters'),
})
export async function updatePasswordAfterRecovery(
data: z.infer<typeof updatePasswordSchema>
) {
const { success } = await rateLimit('update-password', 5, 60)
if (!success) {
return { error: 'Too many attempts. Please try again later.' }
}
const parsed = updatePasswordSchema.safeParse(data)
if (!parsed.success) {
return { error: parsed.error.errors[0].message }
}
const supabase = await createServerClientWithCookies()
// Confirm the recovery session is present. If not, the user landed on
// /settings/password without going through the email link.
const { data: claims, error: claimsError } = await supabase.auth.getClaims()
if (claimsError || !claims) {
return { error: 'Recovery session not found. Request a new reset link.' }
}
const { error: updateError } = await supabase.auth.updateUser({
password: parsed.data.password,
})
if (updateError) {
return { error: 'Failed to update password. Try again.' }
}
// Rotate every session for this user. The recovery session is invalidated;
// any other devices will need to re-login with the new password.
await supabase.auth.signOut({ scope: 'global' })
return { success: 'Password updated. Sign in with your new password.' }
}
Four notes on this handler. First, the rate limit key is distinct from the other auth Server Actions (login, signup, reset); they shouldn't share a bucket because they're attacked differently. Second, getClaims is the session check; it parses the session cookie server-side and returns the claims if the session is valid (the JWT and session management pillar covers getClaims vs getUser vs getSession in depth). Third, the signOut({ scope: 'global' }) call is the load-bearing line for Failure 2 and Failure 3 mitigations; without it, the recovery session lingers. Fourth, the user-facing error messages are deliberately uniform: "Failed to update password" doesn't tell an attacker whether the session was invalid, the password was reused, or the database call failed.
The page itself sets Referrer-Policy: no-referrer via Next.js's route segment config:
// app/(dashboard)/settings/password/page.tsx
import { UpdatePasswordForm } from '@/components/forms/update-password-form'
export const metadata = {
title: 'Update password',
// Belt-and-braces: even with PKCE, no third-party request from this
// page can carry a Referer. See MDN Referrer-Policy [\[4\]](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy).
other: {
referrer: 'no-referrer',
},
}
export default function UpdatePasswordPage() {
return <UpdatePasswordForm />
}
In a Next.js App Router layout, the metadata.other.referrer value emits a <meta name="referrer" content="no-referrer"> tag on the page. If you set the header at the middleware/proxy layer instead, the same effect is achieved via Referrer-Policy: no-referrer in the HTTP response headers. The Next.js security headers guide covers the broader header pattern; the change-password page is the single route where the strictest possible Referrer-Policy is the right default.
What SecureStartKit ships today is the request side (/reset-password page + resetPassword Server Action with rate-limit + Zod), and the recovery callback wiring (/auth/callback route that does the PKCE code exchange and redirects to /settings/password). The update-form page above is left to the user because the design of the form (single-password, password + confirm, password + strength meter) is product-specific and the security perimeter is the Server Action above, not the form. Build the form for your product; pin the Server Action down.
Why we wire the reset through /auth/callback, not the implicit fragment
The SecureStartKit Server Action calls resetPasswordForEmail with a specific redirectTo:
const { error } = await supabase.auth.resetPasswordForEmail(
parsed.data.email,
{
redirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback?next=/settings/password`,
}
)
Two architectural choices in that line do load-bearing work. The first is that the redirect points at /auth/callback, not at /settings/password directly. The /auth/callback route is the shared PKCE token-exchange endpoint for OAuth, magic links, AND recovery; it calls supabase.auth.exchangeCodeForSession(code) and 302-redirects to the next parameter on success. That structure routes the recovery flow through the same server-side code exchange the OAuth flow uses, which kills Failure 1 (token in URL the user lands on) and Failure 3 (token reusable until expiry) on the same handler.
The second choice is that next=/settings/password is hardcoded. If the application instead passed the user-controlled next through (as the OAuth flow legitimately does), an attacker who could craft a recovery email with next=https://attacker.com could redirect the user-with-recovery-session off-site to a page that captures the session cookies. The /auth/callback handler already includes the next.startsWith('/') check that closes the open-redirect surface for OAuth (see the OAuth, magic links, and MFA pillar for the broader allowlist-hardening pattern), but for the recovery flow the safer default is hardcoded: only the change-password route is a valid landing for a recovery session.
The shape of the trade-off matters. The implicit flow is simpler to implement (no /auth/callback route needed; the Supabase client reads the fragment on the change-password page), but it pays for that simplicity with the token sitting in the URL on the page where the user spends seconds typing a password. The PKCE flow adds one HTTP round-trip (the email link goes to /auth/callback, then 302-redirects), but the change-password page never sees the recovery token. For every architectural review that asks "is the extra hop worth it", the answer the five failure modes above give is yes.
When the password reset flow is a sign you need passkeys
Password recovery only exists because passwords exist. Every failure mode above is a consequence of the design choice that an account is identified by something the user knows (a password), and that something can be lost, in which case there needs to be a recovery channel. The recovery channel inherits all the trust assumptions of the email inbox: that no one else has access, that no link preview bot will silently fetch the URL, that the user will complete the flow rather than abandon it. Those assumptions are exactly the failure modes above.
The architectural alternative is to make the recovery channel less load-bearing by making the primary credential something that can't be forgotten. Passkeys (WebAuthn-backed credentials, scoped to the device, recoverable via the user's platform sync) shift the threat model away from "what if the user forgets the password" toward "what if the user loses every device they own". For most consumer SaaS, that's a strictly smaller failure surface; for B2B SaaS where the recovery channel is the corporate email anyway, it's a similar surface with fewer protocol-level failure modes.
The realistic path is incremental. Ship the secure password reset flow above; the five failure modes are real and the fixes are concrete. Then, when the product has a clear "passwords are the bottleneck on activation" signal, add passkeys as an optional secondary credential. The OAuth, magic links, and MFA pillar covers the related credential flows, including the magic-link variant that lets users avoid passwords entirely. For the broader audit of which authentication failures matter most in production, the pre-launch security checklist and the SaaS Security Checklist tool cover password reset alongside the rest of the auth surface, and the OWASP A07 mapping walks the full identification-and-authentication failure category in the Next.js + Supabase context.
The password reset flow is one of the highest-attacker-value, lowest-developer-attention surfaces in an authentication system. The five failure modes above are the audit. The fixes ship in a handful of lines. SecureStartKit wires the secure version by default (PKCE through /auth/callback, rate-limited Server Action, hardcoded redirect target), so the change you need to make in your own code is small enough to do in an afternoon.
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
- CWE-640: Weak Password Recovery Mechanism for Forgotten Password— cwe.mitre.org
- Passwords, Supabase Auth Docs— supabase.com
- Rate Limits, Supabase Auth Docs— supabase.com
- Referrer-Policy, MDN Web Docs— developer.mozilla.org
- resetPasswordForEmail, Supabase JavaScript Reference— supabase.com
Related Posts
OWASP Top 10:2025 for Next.js + Supabase Apps
OWASP Top 10:2025 mapped to Next.js + Supabase failure modes plus the architectural defenses that prevent each category. With 2026 CVEs.
Next.js CSRF, XSS, SQLi: The 3-Layer Defense [2026]
CSRF, XSS, and SQL injection prevention in Next.js. Three architectural defenses tied to OWASP A05:2025 and the 2026 Next.js injection CVEs.
Supabase OAuth, Magic Links, MFA in Next.js [2026]
Secure OAuth, magic links, and MFA in Supabase + Next.js. PKCE flow, redirect URL allowlists, AAL2 step-up, and 5 implementation failure modes.