HomeGuidesSupabase password reset security: 7 gaps, tested
Guide
Supabase password reset security: 7 gaps, tested
Supabase resetPasswordForEmail can still reveal which emails exist, email scanners can burn the link, and old tokens outlive a reset. What we tested and fixed.
Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.
You asked Cursor, Lovable, Bolt or Claude Code for a "forgot password" flow on Supabase. It works: a form calls resetPasswordForEmail, an email arrives, a page calls updateUser({ password }). The parts that break are the ones you never see in a happy path test: what the form says for a second request, what an email scanner does to the link, what a stolen session can change, and what happens to other devices afterwards.
This guide walks through seven gaps in a typical AI-written Supabase reset flow, what we saw when we tested each one against the real Supabase Auth server, and the fixes. Where your reset links send people is covered separately in Supabase Auth redirect URLs and wildcards, so this guide only touches redirectTo briefly.
Tested on October 29, 2026. We built the open source Supabase Auth server from source (commit c3348e8; the newest master, fcc491d, only adds a SECURITY.md file) and ran it with its 76 migrations on PostgreSQL 17.11, PostgREST 16.5 for the Data API, a local mail catcher, and @supabase/supabase-js 2.118.0 on Node 22.23.4. The reset page fix also ran in a Next.js 16.4.3 app with @supabase/ssr 0.12.7. We tested with default settings and then with Secure password change, require current password, a custom recovery email template, an 8 second OTP expiry and a 1 second email frequency. We did not test on a hosted Supabase project, so hosted defaults and dashboard behavior come from Supabase's docs, not from us. VibeLocked is independent and not affiliated with Supabase.
The short version
- The second request gives the account away. The first
resetPasswordForEmailcall looks the same for real and unknown emails. A repeat within 60 seconds returns a 429 for real accounts only. - Email scanners can burn the link. The default link is used up by the first GET. The user then sees
otp_expired. - PKCE links break on a second device. The code verifier lives in the browser that asked for the reset.
- The reset link is a full sign in. Whoever opens it gets a session, whether or not they change the password.
- The change password page trusts any session. Secure password change only applies to sessions older than 24 hours.
- Other devices keep reading data for up to an hour. Their sessions are deleted, but their access tokens stay valid until they expire.
PASSWORD_RECOVERYis a UI hint, not a check. It is an event in the browser, not proof of anything on your server.
1. "If an account exists" is not enough
Supabase's password guide says: "To prevent user enumeration, resetPasswordForEmail() doesn't reveal whether an account exists for the given email address." That holds for the first request. It does not hold for the second.
The Auth source (recover.go) returns an empty 200 right away when no user matches. For a real user it goes on to send the email, and before that it checks a per user frequency limit. Supabase's rate limits page lists /auth/v1/recover with "a 60 seconds window before a new request is allowed for the same user." Unknown emails have no user, so they never hit that window.
| Request | Real account | Unknown email |
|---|---|---|
First resetPasswordForEmail |
data: {}, no error, 1 email sent |
data: {}, no error, no email |
| Second request within 60 s | 429 over_email_send_rate_limit: "For security purposes, you can only request this after 59 seconds." |
data: {}, no error |
Raw POST /auth/v1/recover |
429 with the same message |
200 {} |
| Median time, first request (20 each) | 17.6 ms | 4.7 ms |
So a script can ask for two resets per address and sort a list of emails into "has an account" and "does not." The timing gap is real too, but ours was measured with a mail catcher on the same machine, so read it as a direction, not a number you will see in production. OWASP's Forgot Password Cheat Sheet asks for both: "Return a consistent message for both existent and non-existent accounts" and "Ensure that the time taken for the user response message is uniform."
What AI-written forms usually do wrong:
- Show
error.messagefrom Supabase in the UI, which prints the 429 text for real accounts only. - Look the email up in a
profilestable first and say "No account found." That is enumeration by design, and it usually meansprofilesis readable by anyone, which is its own problem. The signup form leaks the same way, and the same fixes apply: Supabase signup user enumeration. - Show a different screen for success and failure.
The fix is to treat over_email_send_rate_limit exactly like success, pad the response time, and put CAPTCHA on the form (Supabase supports hCaptcha and Turnstile; see Supabase Auth security settings). A Server Action keeps the Supabase error out of the browser entirely:
// app/forgot-password/actions.ts
'use server'
import { createClient } from '@/utils/supabase/server'
const SAME_MESSAGE =
'If an account exists for that email, we sent a reset link. Check your inbox and spam folder.'
export async function requestReset(_prev: unknown, formData: FormData) {
const email = String(formData.get('email') ?? '').trim()
const started = Date.now()
const supabase = await createClient()
const { error } = await supabase.auth.resetPasswordForEmail(email)
// 429 over_email_send_rate_limit only happens for real accounts. Never show it.
if (error && error.code !== 'over_email_send_rate_limit') {
console.error('password reset request failed', error.code)
}
// Answer in about the same time whether or not the account exists.
const wait = 1200 - (Date.now() - started)
if (wait > 0) await new Promise((r) => setTimeout(r, wait))
return { message: SAME_MESSAGE }
}
If you call Auth from your server like this, every request reaches Supabase from your server's IP, so Supabase's per IP limits now apply to all your users together. The login brute force guide covers forwarding the user's IP and adding your own per account limit. With CAPTCHA on, pass the token as options.captchaToken.
2. Email scanners use up the link
The default recovery email uses {{ .ConfirmationURL }}, which points at https://<project>.supabase.co/auth/v1/verify?token=...&type=recovery&redirect_to=.... A GET to that URL verifies the token and redirects with the session. Supabase's email templates guide warns that some providers "prefetch URL links from incoming emails (e.g. Safe Links in Microsoft Defender for Office 365)", so the link "will be consumed instantly."
| What happened first | Then the user clicks | Result for the user |
|---|---|---|
| Nothing | Click | 303 to your page with #access_token=...&type=recovery |
| The same link was clicked once | Click again | 303 with #error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired |
| A scanner sent a GET | Click | Same otp_expired error |
| A scanner sent a HEAD | Click | Worked. HEAD got a 405 and did not use the token |
| PKCE flow, scanner GET | Click | ?error=access_denied&error_code=otp_expired (query and fragment) |
| A second reset was requested | Older link | otp_expired. Only the newest link worked |
| Link older than the OTP expiry | Click | otp_expired (we set the expiry to 8 seconds and waited 9.5) |
The same error code covers all of these, so "my reset link is expired" reports usually mean one of: a scanner, a double click, a second request, or a slow user. In self-hosted Auth the default expiry is one day (configuration.go); on a hosted project check the email OTP expiry in your Auth settings rather than assuming.
Fix: make the user press a button. Send a link to your own page with {{ .TokenHash }} and verify only on submit. The docs list this as option 2 (a page "where they can click on a button to confirm the action"); option 1 is sending the 6 digit {{ .Token }} and having the user type it. Recovery email template:
<h2>Reset your password</h2>
<p><a href="{{ .SiteURL }}/auth/reset?token_hash={{ .TokenHash }}">Reset password</a></p>
<p>Or enter this code on the reset page: {{ .Token }}</p>
The page renders a form, and nothing touches Auth until the user submits:
// app/auth/reset/page.tsx
import { confirmReset } from './actions'
export const metadata = { referrer: 'no-referrer' }
export default async function ResetLanding({
searchParams,
}: {
searchParams: Promise<{ token_hash?: string }>
}) {
const { token_hash } = await searchParams
if (!token_hash) return <p>This reset link is incomplete. Request a new one.</p>
return (
<form action={confirmReset}>
<input type="hidden" name="token_hash" value={token_hash} />
<button type="submit">Continue to reset your password</button>
</form>
)
}
// app/auth/reset/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export async function confirmReset(formData: FormData) {
const token_hash = String(formData.get('token_hash') ?? '')
const supabase = await createClient()
const { error } = await supabase.auth.verifyOtp({ token_hash, type: 'recovery' })
if (error) redirect('/forgot-password?expired=1')
redirect('/account/update-password') // fixed path, never taken from the URL
}
We ran this page and action in a Next.js 16.4.3 app (next build and next start, @supabase/ssr 0.12.7) against our local Auth server, with the reset requested by a PKCE client. Two plain GETs and a HEAD of the email link, like a scanner, returned the page with the button and did not use the token. A browser that clicked the button landed on /account/update-password signed in. A second browser that opened the same link and clicked was sent to /forgot-password?expired=1. A scanner that runs JavaScript and clicks buttons could still use the link, so keep the expiry short.
In our supabase-js test verifyOtp({ token_hash, type: 'recovery' }) signed the user in once, and a second call with the same hash failed with otp_expired. A wrong 6 digit code returned "Token has expired or is invalid" and did not burn the real code; the right code still worked after it. Supabase limits /auth/v1/verify per IP (the rate limits page says 30 requests per 5 minutes by default).
Two notes on the docs' own example. The Next.js app/auth/confirm/route.ts in the password guide calls verifyOtp inside GET, so a scanner that fetches that URL verifies the token the same way it would with the default link. And it redirects to next from the query string. Keep next to fixed paths on your own site, or an attacker can send users to any site after a real reset email; see open redirects in Auth.js, Supabase, and Clerk.
The referrer: 'no-referrer' line follows OWASP's advice for reset pages ("Referrer Policy tag with the no-referrer value"), because a token_hash in the URL is usable by anyone who sees it, unlike a PKCE code.
3. PKCE reset links fail on a second device
With flowType: 'pkce' (the default in @supabase/ssr), resetPasswordForEmail stores a code verifier in the browser's storage, and the email link comes back as ?code=.... In our test:
- Same browser:
exchangeCodeForSession(code)worked, and the session'samrwasrecovery. - Different browser (the user asked on a laptop, opened the email on a phone): it failed with
pkce_code_verifier_not_found, "PKCE code verifier not found in storage. This can happen if the auth flow was initiated in a different browser or device".
The token_hash template from fix 2 avoids this. A reset requested by a PKCE client produced a token_hash starting with pkce_, and verifyOtp with that hash still worked from a fresh client with no verifier. The trade off is the one above: the hash is the whole secret, so keep it out of logs and referrers, and verify it once on your server.
4. The reset link is a full sign in
Opening the link does not just unlock a password form. It creates a normal session. In our test the implicit and token_hash sessions had amr method otp and aal1, with a session_id, the same as a magic link sign in. The PKCE session had amr recovery. Anyone who gets the link, from a forwarded email, a shared inbox or a loose redirect allow list, is signed in as that user and can use your app before or instead of changing the password.
That is why your Redirect URLs list matters so much for reset emails: a bad redirectTo falls back to Site URL, but a wildcard that matches someone else's host hands them the session.
What helps:
- Short expiry. Set the email OTP expiry to the shortest window your users can live with.
- One live link. Auth already replaced older links with each new request in our test.
- Send users straight to the password form after verify, not to the dashboard.
- Tell the user. Auth has an optional "password changed" notification email (
GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLEDin self-hosted Auth, off by default in the source).
The email address is the other half of this. Whoever can change it can request resets for good, and Auth asks for no password or nonce before an email change. We tested that path in Supabase email change security.
5. The change password page trusts any session
The usual AI-written update page checks "is there a session" and calls updateUser({ password }). That works for reset links, and it also works for anyone holding a stolen session, a borrowed laptop or an XSS token. Two settings tighten it, and both have gaps worth knowing.
| Setting (self-hosted env name) | Session | updateUser({ password }) result |
|---|---|---|
| Defaults | Password sign in, fresh | Changed, no questions |
Secure password change (..._REQUIRE_REAUTHENTICATION=true) |
Password sign in, fresh | Changed, no nonce needed |
| Secure password change | Session older than 24 hours (we moved created_at back 25 hours in the database) |
400 reauthentication_needed; after reauthenticate() emailed a 6 digit nonce, updateUser({ password, nonce }) worked |
Require current password (..._REQUIRE_CURRENT_PASSWORD=true) |
Password sign in | 400 current_password_required; wrong current_password gave current_password_invalid; the right one worked |
| Require current password | Reset link, implicit, token_hash or PKCE |
Changed without current_password |
| Require current password | Magic link sign in | Changed without current_password |
The 24 hour rule matches Supabase's password security docs: "A user is considered recently logged in if the session was created within the last 24 hours." So Secure password change does nothing against a token stolen today.
The magic link row is the surprise. In user.go the current password check is skipped when session.IsRecovery() is true, and in factor.go that is true for otp, magiclink and recovery sessions. That makes sense for resets (the user forgot the password), but it means a session from any email code or link can set a new password with no current password. If your app offers magic links, a stolen magic link session can still take over the account's password.
What to do:
- Settings page: turn on both Secure password change and require current password, and ask for the current password in the form. supabase-js supports
current_passwordfrom v2.102.0, per the password guide. - Reset page: let the recovery session through (it has no current password to give), but only reach it from the button flow in fix 2, and send users there directly after verify.
- Do it on the server. Call
updateUserfrom a Server Action that reads the session with the server client, and map error codes to your own messages:
// app/account/update-password/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
const MESSAGES: Record<string, string> = {
current_password_required: 'Enter your current password.',
current_password_invalid: 'Your current password is not correct.',
reauthentication_needed: 'We emailed you a code. Enter it to confirm this change.',
same_password: 'Choose a password you have not used here before.',
weak_password: 'Choose a longer, less common password.',
}
export async function updatePassword(_prev: unknown, formData: FormData) {
const supabase = await createClient()
const { data } = await supabase.auth.getUser()
if (!data.user) redirect('/forgot-password')
const password = String(formData.get('password') ?? '')
const current = String(formData.get('current_password') ?? '')
const nonce = String(formData.get('nonce') ?? '')
const { error } = await supabase.auth.updateUser({
password,
...(current ? { current_password: current } : {}),
...(nonce ? { nonce } : {}),
})
if (error?.code === 'reauthentication_needed') await supabase.auth.reauthenticate()
if (error) return { error: MESSAGES[error.code ?? ''] ?? 'Could not change the password. Try again.' }
redirect('/account?password=changed')
}
getUser() asks Auth to confirm the session instead of trusting the cookie. Why that matters: getSession vs getUser vs getClaims.
6. Other devices keep reading data until their token expires
OWASP suggests you "invalidate the sessions automatically" after a reset. Supabase's sessions guide says a session ends when "the user changes their password or performs a security sensitive action." Our test agreed for sessions and showed the gap for access tokens.
Setup: one user signed in on three devices, with one private row protected by user_id = auth.uid() RLS. Then the password was changed on one device, both with a normal session and through a reset link.
| Check on another device | Result |
|---|---|
Rows in auth.sessions for the user |
3 before, 1 after (only the device that changed it) |
refreshSession() |
400 refresh_token_not_found |
GET /auth/v1/user with its old access token |
403 session_not_found, "Session from session_id claim in JWT does not exist" |
GET /rest/v1/notes with its old access token |
200 with the private row, 3,599 seconds left on the token |
The Data API checks the JWT signature and expiry, not whether the session still exists (the same is true after any sign out; see Supabase signOut scopes and session revocation for what each scope revokes). So a thief who had the access token keeps reading and writing through RLS for the rest of its life, up to the JWT expiry (3,600 seconds by default). The sessions guide describes the hard guarantee for tables that need it: check the token's session_id against auth.sessions. As a restrictive policy:
create schema if not exists private;
create or replace function private.session_is_active()
returns boolean
language sql stable security definer set search_path = ''
as $$
select exists (
select 1 from auth.sessions
where id = nullif(auth.jwt() ->> 'session_id', '')::uuid
)
$$;
revoke all on function private.session_is_active() from public, anon;
grant usage on schema private to authenticated;
grant execute on function private.session_is_active() to authenticated;
create policy "session must be active"
on public.notes
as restrictive
for all
to authenticated
using ((select private.session_is_active()));
With that policy added, the same old token got 200 [], while the device that changed the password still worked. It costs one indexed lookup per query, so apply it to sensitive tables first. Keep the function in a schema the Data API does not expose; SECURITY DEFINER functions anyone can call explains why. A shorter JWT expiry is the other lever, at the cost of more refreshes. The same check, used to cut off a removed teammate, is in revoking a removed team member's access.
7. PASSWORD_RECOVERY is a hint for the UI
In @supabase/auth-js 2.118.0, onAuthStateChange emits PASSWORD_RECOVERY instead of SIGNED_IN in three places: when it reads type=recovery from the URL, when a PKCE exchange came from a recovery request, and when verifyOtp is called with type: 'recovery'. Using it to open the "choose a new password" form is fine. Using it as the gate is not. It only exists in the browser, the user is already signed in when it fires, and a page that shows the password form only on that event still lets any session post to your update action. Put the rules from fix 5 on the server.
What we tested and what we did not
Tested (scripts and outputs kept with our notes): resetPasswordForEmail for real and unknown emails, first and repeat requests, and raw /auth/v1/recover responses and timing; default links with GET, repeat GET and HEAD; implicit and PKCE flows, same and different storage; a token_hash template with verifyOtp, the 6 digit code, a wrong code, a newer request and an 8 second expiry; updateUser with defaults, Secure password change (with a session aged in the database), require current password, and with password, recovery and magic link sessions; other devices' refresh tokens, /auth/v1/user and Data API reads after a password change, before and after the restrictive policy.
We also ran the reset landing page and its action from fix 2 in a Next.js 16.4.3 app with a real browser (Chromium through Playwright). All four code samples type-check with TypeScript 7.0.2 and build with next build.
Not tested: a hosted Supabase project, the dashboard, Supabase's built-in email service or real inbox scanners (we simulated a scanner with plain GET and HEAD requests); CAPTCHA; the requestReset and updatePassword actions in a running app (the Auth calls in them are the ones we tested with supabase-js); timing with real SMTP; mobile deep links.
Error codes you will see
| Code | What it means in a reset flow |
|---|---|
otp_expired |
Link or code already used, replaced by a newer one, or past expiry |
over_email_send_rate_limit |
Same user asked again within the window. Only real accounts get it, so do not show it |
pkce_code_verifier_not_found |
PKCE link opened in a different browser or after storage was cleared |
reauthentication_needed |
Secure password change is on and the session is older than 24 hours |
current_password_required / current_password_invalid |
Require current password is on and the session is not from an email link or code |
same_password |
New password matches the old one |
session_not_found / refresh_token_not_found |
The session was ended, for example by a password change on another device |
The full list is in Supabase's Auth error codes.
Checklist
- Forgot password form shows one message for success, unknown email and
over_email_send_rate_limit - No lookup in your own tables to check if an email exists before sending a reset
- Reset requests come from the server or carry a CAPTCHA token; response time padded
- Recovery email links to your own page with
{{ .TokenHash }}, andverifyOtpruns only on a button submit - After verify, users land on a fixed password page, not a
nextURL from the query string - Redirect URLs list has no wildcard that matches hosts you do not own
- Email OTP expiry is short
- Secure password change and require current password are on for the settings page
-
updateUserruns in a Server Action that checksgetUser()first - Sensitive tables check the session is still active, or JWT expiry is short
- Password changed notification email is on
Copy-paste prompt for your AI coding tool
Audit the Supabase password reset and change password flows in this app. Do not print or ask for any keys. Find every call to resetPasswordForEmail, verifyOtp, exchangeCodeForSession, updateUser and reauthenticate, and any onAuthStateChange handler for PASSWORD_RECOVERY. Flag: UI that shows a Supabase error message from the reset request (over_email_send_rate_limit only happens for real accounts); any query that checks whether an email exists before sending a reset; redirectTo or next values built from the URL or user input; routes that call verifyOtp or exchangeCodeForSession inside a GET handler; change password code that runs in the browser or only checks that a session exists; updateUser calls that never send current_password on the account settings page; pages that rely on the PASSWORD_RECOVERY event as their only check. Propose: one generic reset response with padded timing, a recovery email template that links to our own page with {{ .TokenHash }} and verifies on a button submit, a fixed post-reset path, a Server Action for updateUser that maps error codes to our own messages, and a restrictive RLS policy that checks auth.sessions for sensitive tables. Show the diff before applying it.
More prompts: security review prompt for AI coding tools.
Check what your app exposes with LeakCheck
LeakCheck scans a live app URL or a public GitHub repo, read only. It looks for exposed keys such as a Supabase service role key in your bundle, which would let anyone skip every rule in this guide. It does not request password resets, read your email templates or test your Auth settings, so the checks above are still yours to do.
Sources
- Supabase: Password-based Auth (resetting a password, PKCE template, current_password)
- Supabase: Password security (reauthentication, require current password)
- Supabase: Email templates (variables, email prefetching)
- Supabase: Auth rate limits
- Supabase: User sessions
- Supabase: Auth error codes
- Supabase: Redirect URLs
- Supabase JavaScript reference: resetPasswordForEmail
- Supabase JavaScript reference: updateUser
- Supabase JavaScript reference: verifyOtp
- Supabase JavaScript reference: reauthenticate
- Supabase JavaScript reference: onAuthStateChange
- Supabase Auth source: recover.go (unknown email returns early)
- Supabase Auth source: mail.go (per user frequency limit on recovery emails)
- Supabase Auth source: user.go (reauthentication and current password checks)
- Supabase Auth source: factor.go (which sessions count as recovery)
- Supabase Auth source: user.go model (sessions ended on password change)
- OWASP Forgot Password Cheat Sheet
Questions
Does Supabase resetPasswordForEmail reveal whether an email has an account?
Not on the first request. In our test it returned an empty success for both a real and an unknown email, and only the real one got an email. A second request within 60 seconds is different: the real email got a 429 over_email_send_rate_limit error and the unknown email still got success. If your form shows error.message, anyone can tell which emails are registered. Show the same message for success and for that error.
Why does my Supabase reset link say otp_expired or Email link is invalid or has expired?
The link is single use, and something used it first. In our test a second GET of the same link, or a click after a scanner fetched it, redirected with error_code=otp_expired. A newer reset request also replaced the older link, and links stop working after the email OTP expiry. Supabase's docs name Microsoft Defender Safe Links as one scanner that prefetches links.
How do I stop email security scanners from using up the reset link?
Do not verify on a GET. Point the email at your own page with {{ .TokenHash }}, show a button, and call verifyOtp({ token_hash, type: 'recovery' }) only when the user submits. Or send the 6 digit {{ .Token }} code and have the user type it. In our test a HEAD request did not use up the default link, but a GET did.
Why does the PKCE reset link fail on another device?
With the PKCE flow, supabase-js stores a code verifier in the browser that asked for the reset. In our test, exchangeCodeForSession in a different browser failed with pkce_code_verifier_not_found. A token_hash link verified with verifyOtp worked from a fresh client, even when a PKCE client had requested the reset.
Does changing the password sign out other devices in Supabase?
Their sessions end, but not instantly everywhere. In our test, updateUser({ password }) deleted every other session: their refresh failed and /auth/v1/user said session_not_found. Their old access token still read the user's rows through the Data API for the rest of its lifetime, about an hour by default. A restrictive RLS policy that checks the session still exists closed that gap.
Does Secure password change make updateUser require reauthentication every time?
No. With it on, Auth only asks for a nonce when the session is more than 24 hours old. A fresh session, including the one from a reset link, can change the password directly. The separate require current password setting asks for current_password, but Auth skips it for sessions that came from a recovery link, a magic link or an email OTP.