Supabase email_address_invalid on password reset
email_address_invalid from resetPasswordForEmail() means GoTrue's email validator rejected the recipient domain — it's on a hardcoded blocked-host list (example.com, test.com, gamil.com, localhost…) or its MX lookup came back empty. The validator only runs on the code path that actually sends mail, so a real user gets HTTP 400 while a nonexistent address gets a clean success. Test with a domain that has working MX records and the error disappears.
You call resetPasswordForEmail() for a user you can see sitting in the Auth dashboard — confirmed, signing in fine yesterday. Back comes a 400:
{ "code": "email_address_invalid", "message": "Email address \"someone@example.com\" is invalid" }
So you check the address. It parses. It has an @. It's the same string that's in auth.users.email. Then you try a completely made-up address at the same domain to compare — and that one succeeds. The address that works is the fake one.
Nothing about that reads as "your domain is on a blocklist", which is why this one burns an afternoon.
Why does resetPasswordForEmail return email_address_invalid for a real user but succeed for a fake one?
Because the two requests take different code paths, and only one of them reaches the email validator.
That validator lives in GoTrue's mail client (internal/mailer/validateclient/validateclient.go), not in its request parsing — it runs at the moment Auth is about to hand your address to an SMTP provider, and only when extended or service-based email validation is switched on for the project. It fails in three distinct ways, and all three surface as the same email_address_invalid code:
invalid_email_format— the address doesn't parse, or it parses as an RFC 5322 address with a display name (Alice <alice@acme.com>), which isn't valid as a signup identity.invalid_email_dns— the host is on a hardcoded blocked list, or an MX lookup for it returned nothing.invalid_email_address— the full address matches a known-bad static entry.
The blocked-host list is short and literal, and it's where most reports of this error land:
test example invalid local localhost
test.com example.com example.net example.org
gamil.com gamai.com anonymous.com email.com
Plus anything ending in .localhost. The source comments say why: the example.* domains are special-cased because they have DNS records and generate a high volume of bounce-backs, and gamil.com / gamai.com are there as frequent typos of gmail.com.
If your domain isn't on that list, you're in MX territory — a custom domain you own but never configured for mail, a bare app.mycompany.io with A records and no MX, fails the same check. That case is the one people miss, because the domain is real.
Now the part that explains the asymmetry. /auth/v1/recover is built to stay silent about whether an account exists. The Supabase password docs put it plainly: to prevent user enumeration, resetPasswordForEmail() doesn't reveal whether an account exists for the address, and when no user is associated with it, no email is sent although the method still returns without an error.
No user means no email is sent, which means the validator never runs, which means there is nothing to fail. An existing user takes the full path, reaches the send, and trips the check.
That's not a theory. It's filed as supabase/auth#2702, opened 16 August 2026 and still open at the time of writing, with the reproduction stated as plainly as it can be: an existing confirmed user at @example.com gets HTTP 400, email_address_invalid, and a missing address at the same domain gets success with no error.
The fix, if you just want the email to send
Three things to check, in order:
1. Stop testing with example.com. This is the cause most of the time. Use an inbox service with a routable domain (Mailtrap, Ethereal) or a real domain you control. The blocklist is working as designed; the test fixture is the problem.
2. Check your domain's MX records. One command:
dig +short MX your-domain.com
Empty output means the validator will reject every address at that domain, no matter how real the user is. Add MX records, or send to a domain that has them.
3. Then look past the address. If mail leaves but the link dies, that's a different failure — an invalid flow state or an expired OTP means your redirectTo isn't on the allow list. If nothing arrives and there's no error at all, that is a different problem with its own triage ladder — start with whether the address has an account at all, then check the auth rate limits; reset emails are the first thing to quietly stop during a testing session.
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: "https://app.your-real-domain.com/auth/callback",
});
// Do NOT branch your UI on this error object. See below.
if (error) console.error(error.code, error.message);
The part that matters for security
Look at what those two responses hand an unauthenticated caller. /auth/v1/recover is reachable with the publishable (anon) key alone — that's the whole point of a password reset form. And for any address the validator would reject, the response differs based on whether the account exists:
| Request | Account exists | Response |
|---|---|---|
someone@example.com | yes | 400 email_address_invalid |
nobody@example.com | no | 200 ok |
The one endpoint whose entire design goal is "look identical no matter what" has a branch where it doesn't. That's an account-existence oracle built out of the protection itself.
Be precise about the blast radius, though. It only works for addresses the validator rejects, so nobody is running it against @gmail.com — it is not a general-purpose enumeration tool, and it needs that validation enabled to occur at all. A fix is proposed in supabase/auth#2723, which intercepts the invalid-email error and returns 200 on both paths, with a regression test asserting the two responses match. It was open and unmerged when this was written, so check its status before assuming the behaviour is gone.
The workaround that turns a narrow bug into a wide one
Here is how this becomes your vulnerability rather than Supabase's. The error is confusing, so somebody decides the real fix is to check the address before calling reset at all — and the fastest way to do that is a table the form can read directly:
-- 20260920120000_reset_email_precheck.sql
-- Support keeps asking why "reset password" calls the address invalid,
-- so the form now checks it against our own table before submitting.
create table public.registered_emails (
user_id uuid primary key references auth.users (id) on delete cascade,
email text not null unique
);
grant select on public.registered_emails to anon;
- Warningsupabase/migrations/20260920120000_reset_email_precheck.sql:4
Table created without enabling RLS
Add ALTER TABLE <table> ENABLE ROW LEVEL SECURITY; plus access policies right after the CREATE TABLE. - Warningsupabase/migrations/20260920120000_reset_email_precheck.sql:9
Table granted to the anon role without RLS
Enable RLS on the table (ALTER TABLE <t> ENABLE ROW LEVEL SECURITY;) and add scoped policies, or revoke the grant from anon. Only keep an anon grant for genuinely public data that is still RLS-protected.
That is now a public, unauthenticated, complete list of every email address that has ever signed up — queryable with the anon key straight from the browser, no blocked domain required. You have replaced a narrow 400-vs-200 tell with a full export.
It's the same trade as the signup "email already exists" workaround, and the same one behind a hand-rolled check-email route: a real security control gets read as a bug, and removing it feels like fixing it.
If you genuinely need a "did you mean to sign up?" affordance, put it after the auth call, not before it. Always show "if that address has an account, we've sent a reset link," and let the email itself be the signal. That costs you nothing and leaks nothing.
A 60-second self-check
# 1. Any table mirroring user emails into the public schema?
grep -rniE "create table .*(email|profile)" supabase/migrations/
# 2. Any of them granted to anon, or with a permissive SELECT policy?
grep -rniE "to anon|using \(true\)" supabase/migrations/
# 3. Any route that exists only to answer "does this email exist?"
grep -rln "listUsers\|auth.users" app/ --include=route.ts
Hits on 1 and 2 together, or anything from 3 without an auth guard in front of it, is the pattern above. GuardLayer flags the first two shapes automatically — a public table created without RLS, and a table granted to anon with no policy behind it — which is what the scan output further up is showing. It has no idea the column holds email addresses; the missing RLS is enough.
FAQ
What does email_address_invalid mean in Supabase?
GoTrue's email validator rejected the recipient before sending. In practice that means a blocked host such as example.com or test.com, a domain whose MX lookup returned nothing, or an address that doesn't parse. It does not mean the user is missing.
Why does it happen for a real user but not a fake one? The validator only runs on the path that actually sends mail. A nonexistent address never reaches it, because Supabase deliberately sends nothing and returns success to avoid revealing that the account doesn't exist.
My domain is real. Why is it still rejected?
Check MX records with dig +short MX your-domain.com. A domain with A records but no mail configured fails the DNS check exactly like a blocklisted one.
Is this a security vulnerability in my app? Not by itself. It's an account-existence tell inside hosted Supabase Auth, limited to addresses the validator rejects, and tracked upstream. It becomes your problem only if you "fix" it by building an email-existence lookup of your own.
Can GuardLayer detect this?
No. The behaviour lives in hosted GoTrue, not in your repository, and no static scan of your code can see it. What GuardLayer does catch is the workaround: public tables created without RLS (including the email-mirror table above), tables granted to anon, edge functions and server actions with no auth check, and the service role key escaping the server.
Won't a generic message hurt UX? No. "If an account exists for that address, we've sent a reset link" is the standard copy precisely because it's both honest and non-enumerable. A user who typed the wrong address finds out from their empty inbox, not from your API.
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.