← All posts
·7 min read·GuardLayer

Supabase reset password email not sending

SupabaseAuthPasswordsEnumerationSecurity

resetPasswordForEmail() returning without an error does not mean an email was sent. When no account matches the address, Supabase deliberately sends nothing and still reports success — that is documented anti-enumeration behaviour, not a bug. Confirm the address really has an account before you touch SMTP, and check your redirect allow list before you rewrite anything.

The report is always the same shape: no error in the console, no email in the inbox, nothing obviously wrong in the dashboard. Because there is no failure to chase, this is one of the easiest Supabase problems to spend a whole afternoon on.

Three causes account for nearly all of it. Work them in order — the first one is free to check and is the one people skip.

Why does resetPasswordForEmail succeed but no email arrives?

Most often because the address has no account, and Supabase is hiding that from you on purpose. The Supabase password docs say it outright:

"To prevent user enumeration, resetPasswordForEmail() doesn't reveal whether an account exists for the given email address. When no user is associated with the address, Supabase Auth won't send an email, though the method still returns without an error."

If your app's reset form reports "check your inbox" whenever error is null, it is reporting the absence of a failure, not the presence of an email. The two are not the same thing, and no client-side code can tell them apart. By design.

So step one is to look from the side the API will not answer from:

-- Run in the SQL editor. Does the address actually exist,
-- and is it the form you think it is?
select id, email, email_confirmed_at, last_sign_in_at
from auth.users
where email ilike 'user@example.com';

Watch for the near-misses that produce exactly this symptom: a trailing space, a different case (GoTrue lowercases addresses on the way in), a +tag variant, or an address that lives on a different Supabase project than the one your NEXT_PUBLIC_SUPABASE_URL points at. Staging-versus-production is a common one.

Cause 2: you are being rate limited, quietly

Supabase's default email provider is capped, and testing burns through it faster than anyone expects. The per-address frequency window is separate from the hourly cap, and both fail closed without much ceremony — see the full table of Supabase auth rate limits for the numbers.

The frequency window is worth knowing about specifically, because it produces the opposite symptom: an error instead of silence. If some addresses throw "you can only request this after 56 seconds" while others sail through, you are looking at that limit, and it tells you more than it should.

The real fix for a production app is to stop using the built-in provider. Configure custom SMTP (Resend, Postmark, SES) in Authentication → Emails → SMTP Settings. The default sender exists for development, and Supabase says so.

Cause 3: the email sends, but the link is dead on arrival

If the mail arrives and the link bounces the user to your site with an error fragment, nothing is wrong with delivery. redirectTo has to be on the project's redirect allow list, exactly:

const { error } = await supabase.auth.resetPasswordForEmail(email, {
  redirectTo: "https://app.example.com/auth/callback",
});

Add that URL under Authentication → URL Configuration → Redirect URLs. Preview deployments need a wildcard entry (https://*-myteam.vercel.app/**) or they will fail while production works. A link that lands with error_code=otp_expired is usually a mail scanner consuming the token first, and one that dies on flow_state_not_found is a PKCE mismatch — both are post-delivery problems, not sending problems.

One more that looks like a delivery failure but is not: a 400 with email_address_invalid means GoTrue's validator rejected the recipient domain, and that error fires for real users while nonexistent addresses still get a clean success.

The trap: "fine, I'll check whether the address exists first"

This is the natural next thought, and it is the one to resist. The silence is a control. Removing it costs you something real and buys you a slightly nicer error message.

The version that ships most often is a lookup the form can call directly — a public profiles mirror, an /api/check-email route, an RPC over auth.users. Whatever the shape, the result is an unauthenticated API that answers "does this person have an account here?" for any address on the internet. For a fitness app or a dating app or a debt-consolidation SaaS, that answer is the sensitive part of your product. The same argument applies to the signup form's duplicate-email check, and it applies here for the same reason.

If the UX is bothering you, fix the copy instead:

Check your inbox. If an account exists for that address, we've sent a reset link. Didn't get it? Check spam, or try signing up.

That sentence is honest, it resolves the user's confusion, and it leaks nothing.

The worse trap: rolling your own reset flow

The escalation is predictable. Supabase's reset "doesn't work", so someone rebuilds it — own token table, own email via Resend, own /reset-password page:

-- Supabase's reset email is unreliable, so we send our own from Resend
-- and verify the token on /reset-password.
create table public.password_reset_tokens (
  token uuid primary key default gen_random_uuid(),
  email text not null,
  used boolean not null default false,
  expires_at timestamptz not null default now() + interval '24 hours'
);

create index on public.password_reset_tokens (email);
guardlayer scan · supabase/migrations/20260921130000_password_reset_tokens.sqlLive engine output
Passed with warnings
92/100 · A
  • Warningsupabase/migrations/20260921130000_password_reset_tokens.sql:3

    Table created without enabling RLS

    Add ALTER TABLE <table> ENABLE ROW LEVEL SECURITY; plus access policies right after the CREATE TABLE.

No RLS. Reachable through the Data API with the publishable key that is already in your JavaScript bundle. Which means one select from any browser returns every live reset token alongside the address it belongs to — full account takeover for every user with a pending reset, plus the complete email list you were trying not to leak.

That is not a hypothetical edge case; it is what "create a table and move on" produces on Supabase, and it is the single most common finding in real projects. If you genuinely need your own reset flow, the token table belongs behind RLS with no policy for anon or authenticated, touched only by server code holding the service role key — and the token you email should be a hash of what you store, so a database read is not immediately a takeover.

A 60-second self-check

# 1. Any table you created that never got RLS enabled?
grep -rniE "create table" supabase/migrations/ | wc -l
grep -rniE "enable row level security" supabase/migrations/ | wc -l

# 2. A homemade token/reset/invite table is the one to check first.
grep -rniE "create table .*(token|reset|invite|otp)" supabase/migrations/

# 3. Any route whose only job is answering "does this email exist?"
grep -rln "listUsers\|auth\.users" app/ --include=route.ts

If the two counts in step 1 differ, something is exposed. GuardLayer reports exactly that — the scan above is the real output for the migration in this post, flagging a public table created with no RLS behind it. It cannot see your SMTP settings, your redirect allow list, or whether an email left Supabase's servers; those live in the dashboard, not the repo.

FAQ

Why does Supabase say the password reset succeeded when no email was sent? Because the address has no account. Supabase deliberately returns success in that case so that an attacker cannot use the reset form to discover which email addresses are registered. It is documented behaviour on the password guide.

How do I tell whether the email was actually sent? Not from the client — that is the point. Check auth.users for the address in the SQL editor, and check the Auth logs for a user_recovery_requested entry. If the user row exists and the log entry is there, the problem is downstream in SMTP or in your redirect allow list.

Should I check whether the email exists before calling reset? No. That rebuilds the enumeration hole the silent response exists to close, and it gives an attacker something faster and more reliable than the original. Change the confirmation copy to "if an account exists, we've sent a link" instead.

Reset works locally but not on my deployed app. Usually the redirect URL. Preview and production origins both have to be on the allow list under Authentication → URL Configuration, and preview deployments need a wildcard pattern. Also confirm both environments point at the same Supabase project.

Is the default email provider enough for production? No. It is rate limited for development use. Configure custom SMTP before launch, or your reset and confirmation emails will silently stop during your first busy hour.

Can GuardLayer detect this? Not the missing email — that behaviour is in hosted Supabase Auth and in your dashboard settings, neither of which is in your repository. What it catches is the damage done by the workarounds: tables created without RLS (including homemade reset-token tables), GRANTs to anon, permissive policies, and the service role key leaking to the client.

Catch this before it ships — free

GuardLayer scans every push for this and 33 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading

A Solvion project — see also Reglog — EU AI Act changelog, Proceedly, Solenna and Solvion Solutions.