Supabase signup with an existing email: why no error
Supabase's signUp() deliberately returns a success response with an obfuscated user object when the email already exists, instead of an error — it's an anti-enumeration control, not a bug. Detect it with data.user.identities.length === 0, and never fix it by exposing an email-lookup endpoint or a permissive profiles read policy, which hands attackers the list your app was hiding.
This is the single most misdiagnosed behaviour in Supabase Auth. The thread "Auth no error return on duplicate email" has been open for years, and a Supabase maintainer's answer is one sentence: "The unique email validation message has been removed for security concerns."
Those security concerns are worth understanding, because the workaround everyone reaches for is the vulnerability the platform was protecting them from.
What user enumeration actually costs you
An enumeration oracle is any endpoint that answers "does an account exist for this address?" differently depending on the answer. Given one, an attacker with a list of email addresses — a breach dump, a scraped company directory, a customer list — can filter it down to your users in a few minutes of scripted requests.
That filtered list is what makes the next attack economical:
- Credential stuffing against known-good accounts instead of a 99%-miss list.
- Targeted phishing: "your ACME account needs re-verification" is far more convincing when sent only to people who actually have an ACME account.
- Competitive intelligence: confirming which specific companies' employees use your product.
- Extortion and outing, if simply having an account on your service is sensitive information.
So Supabase declines to answer. signUp() with an existing email returns HTTP 200, no error, and a user object that looks real but isn't — no email is sent, no account is created, nothing changes. From the client, a duplicate signup and a fresh signup are indistinguishable.
The same reasoning explains the generic 60-second windows on recovery and OTP requests and why /auth/v1/recover is designed to behave identically for addresses that don't exist. It's one consistent policy, not a series of unrelated quirks — even if the implementation doesn't always hold the line: open issues supabase/auth#2702 and #2398 both report /recover responding differently for real accounts.
How do I detect that the email is already registered?
Check whether the returned user has any identities. Supabase populates identities as an empty array when the address already belongs to a confirmed account:
const { data, error } = await supabase.auth.signUp({ email, password });
if (error) {
// A real error: weak password, invalid address, rate limit.
return setMessage(error.message);
}
if (data.user && data.user.identities?.length === 0) {
// Already registered and confirmed. Supabase sent nothing.
return setMessage(
"If that address isn't already registered, check your inbox to confirm it."
);
}
setMessage("Check your inbox to confirm your address.");
Two honest caveats.
It doesn't cover the unconfirmed case. As the reporter in discussion #29327 puts it, the identities.length === 0 approach "works fine with email already exist and verified but not with waiting for verification status emails." An address that signed up but never confirmed still returns an identity, so a second signup on it looks like a first.
That gap has a real consequence. The same discussion reports that re-signing up on an unconfirmed address does not overwrite the original password. An attacker who registers someone@company.com first, and never confirms, can retain the password on that address — and if the real owner later signs up and confirms without noticing, the attacker's credential is the live one. If you use email/password signup, treat this as an argument for confirming addresses promptly and for surfacing "an account with this address is pending confirmation" through the email channel, never the API response.
And notice what the message above does. It's phrased so it reads identically whether or not the address exists. That's the point — a message that says "this email is already taken" is the oracle, no matter how you obtained the information.
The fix that recreates the hole
Since the API won't tell you, the obvious move is to ask the database directly: keep emails in public.profiles, let the signup form query it, done.
- Warningsupabase/migrations/20260907120100_email_lookup.sql:3
Overly permissive RLS policy
Scope the policy to the requesting user, e.g. USING (auth.uid() = user_id). Reserve (true) for genuinely public, read-only data.
That policy is a public API endpoint returning every email address in your database. Not a hint about one address at a time — the whole table, to anyone holding the anon key, which ships in your JavaScript bundle by design:
curl "https://<project>.supabase.co/rest/v1/profiles?select=email" \
-H "apikey: <your-public-anon-key>"
You didn't build an enumeration oracle. You built a bulk export. using (true) is the single most consequential thing you can write in an RLS policy, and it's worth understanding why it looks so harmless.
The check_email_exists RPC has the same problem in a smaller package. A SECURITY DEFINER function callable by anon that takes an email and returns a boolean is a perfect oracle: one request per address, no rate limit of its own, no logging that distinguishes it from normal traffic. It leaks one bit at a time instead of everything at once, which is slower for the attacker and no safer for you.
What to do instead
Confirm on collision, by email. This is the UX everyone actually wants, and it's achievable without leaking anything. Duplicate signup detected server-side → send an email to the existing address saying "someone tried to sign up with your address; sign in, or reset your password." The person who owns the inbox gets a useful message. The person probing your API gets the same 200 they'd get for any address.
Give the same answer for everything on the signup path. One response shape, one message, one latency profile. If your "already registered" path returns in 40ms and your real-signup path takes 600ms because it sends an email, timing alone is the oracle. Do the work asynchronously so both paths return at the same speed.
Let the failure surface at sign-in. A user who already has an account will try to sign in, get Invalid login credentials if the password is wrong, and use password reset. That message is identical whether or not the account exists, so it needs no help from you — though its response timing still differs for real accounts, per the open supabase/auth#2674.
Keep the unique constraint. If you write to public.profiles from a trigger, a duplicate raises duplicate key value violates unique constraint "profiles_email_key" (SQLSTATE 23505) inside the trigger, which surfaces as a generic 500. That's the right outcome — do not catch it and turn it into a specific message the client can read.
Turn on leaked password protection. If you're worried about accounts being taken over from lists, checking passwords against HaveIBeenPwned at signup removes far more risk than any signup-form message ever will.
Quick self-check
-- Anything readable by anon that contains an email column is an
-- enumeration surface. This is the query to run.
select c.relname as table_name, a.attname as column_name,
c.relrowsecurity as rls_enabled
from pg_attribute a
join pg_class c on c.oid = a.attrelid
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and a.attname ilike '%email%'
and c.relkind = 'r'
and has_table_privilege('anon', c.oid, 'SELECT');
-- Policies that grant unconditionally. Every one of these needs a reason.
select tablename, policyname, roles, qual
from pg_policies
where schemaname = 'public'
and qual = 'true';
-- Functions anon can call. Check each for an email argument.
select p.proname, p.prosecdef as security_definer
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and has_function_privilege('anon', p.oid, 'EXECUTE');
If the first query returns a table with rls_enabled = false, stop reading and fix that now.
FAQ
Why does Supabase return a fake user instead of an error? To make a duplicate signup indistinguishable from a new one. If the API returned "email already exists", anyone could test addresses against your user base at will.
How do I know if the email already exists, then?
Client-side: data.user.identities.length === 0 after a successful signUp(). It's reliable for confirmed accounts and unreliable for unconfirmed ones — so use it to shape internal behaviour, not to show the user a definitive message.
Is duplicate key value violates unique constraint "users_email_key" the same thing?
It's the database-level version. You'll see it if you insert into auth.users directly via the Admin API, or in your logs when a profiles trigger hits its own unique constraint. Don't relay it to the client.
Can I use the Admin API to check?
auth.admin.listUsers() requires the service role key, so it can only run on your server. Doing that lookup and returning the answer to an unauthenticated client rebuilds the oracle — the key being secret doesn't help if the endpoint in front of it is public.
Is user enumeration really that serious? On its own it's an information leak, not a breach. Its severity is entirely about what it enables: it converts a generic credential-stuffing list into a targeted one, and it makes phishing dramatically more effective. That's why every major auth provider has converged on the same silence.
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.