Supabase signUp returns a user for any password
signUp() never validates a password against an existing account, because it is not a sign-in. If the address already exists but is unconfirmed, GoTrue skips the "user already registered" branch and returns that account's real user row — id, email and user_metadata — with any password at all. Gate your app on data.session, or better a server-side getUser(), never on data.user.
The behaviour is easy to miss because the happy path looks identical. You call signUp(), no error comes back, a user object comes back, and your code moves on to the dashboard. That works correctly for a brand-new signup. For an address that is already half-registered, it hands the caller someone else's account details.
Why does Supabase signUp succeed with the wrong password for an existing email?
Because on that branch there is no password to be wrong. Here is the relevant code from internal/api/signup.go:
if user != nil {
if (params.Provider == EmailProvider && user.IsConfirmed()) || ... {
return UserExistsError
}
// do not update the user because we can't be sure of their claimed identity
}
Three things follow from those four lines:
- A confirmed duplicate takes the
UserExistsErrorpath. That is the obfuscated, anti-enumeration response most people have read about — a fake user with an emptyidentitiesarray. That path is well documented and deliberate. - An unconfirmed duplicate takes neither path. No error, no obfuscation. Execution continues, the confirmation email is re-sent, and the handler serializes the user it already had.
- The submitted password is never compared to anything. GoTrue's own comment says it cannot be sure of the caller's claimed identity, so it refuses to update the stored row — and then returns it.
The row that comes back is the stored one, not an echo of what was submitted. That is the whole issue. supabase/supabase#33325, open since February 2025 and still active, states it directly:
"If you try to signup with an not yet verified email, Supabase instead of returning an error that the user already exists or that isn't validated it returns the user object. Which would be kind of ok, if this was only happening while using the same password that you used on the registration, but it won't validate the password and still return the us[er]"
The reporter's captured response makes the impact concrete: the returned user_metadata contained the original signup's phonenumber, country and an IBAN, because that is what the first person had put there.
What you actually get back, and how to tell the cases apart
| Existing account | HTTP | data.user | data.session | identities |
|---|---|---|---|---|
| none — genuine new signup | 200 | the new user | null (confirmation on) | populated |
| exists, unconfirmed | 200 | the existing user | null | populated |
| exists, confirmed | 200 | obfuscated fake user | null | [] |
Two things are constant down that session column, and they are the fix. With email confirmation enabled, signUp() never issues a session — a session is only minted where user.IsConfirmed() is true. So:
const { data, error } = await supabase.auth.signUp({ email, password });
// WRONG — true in all three rows of the table above.
if (data.user) router.push("/dashboard");
// RIGHT — only true when a real, confirmed session exists.
if (data.session) router.push("/dashboard");
else setMessage("Check your inbox to confirm your address.");
And for anything that grants access rather than renders copy, do not trust the client's word at all. Resolve the user on the server with getUser(), which revalidates the token with the Auth server — the same distinction that makes getSession() unsafe in server code.
There is a product-level fix too, and it is the better one: do not leave accounts unconfirmed for long. A short confirmation-token lifetime, or a re-signup flow that quietly restarts confirmation instead of returning the stored row to the caller, shrinks the window where this is exploitable to almost nothing.
The part that matters for security
Take the table seriously for a moment. Anyone who knows an address that signed up and never clicked the confirmation link can send one unauthenticated request, with a password they invented, and receive:
- the account's real user id,
- confirmation that the address is registered and unconfirmed,
- whatever the original person put in
user_metadata.
That last item is where this turns from an enumeration nuisance into a data leak, because user_metadata is where onboarding forms dump things. It is populated from options.data at sign-up — client-supplied, client-readable — and it routinely ends up holding phone numbers, addresses, company details and, per the issue above, bank details. If you are storing anything there that you would not print on the user's public profile, move it out of user_metadata regardless of this bug.
No session is issued, so this is not a full account takeover on its own. It is worse than it looks anyway, because of what apps do with data.user.
The pattern that turns it into one
Here is the shape that makes this exploitable rather than merely leaky — a server action called with the id the signup form just received:
"use server";
import { createClient } from "@supabase/supabase-js";
// Called from the signup form as soon as signUp() returns data.user.
export async function completeSignup(userId: string, plan: string) {
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
await admin.from("subscriptions").insert({ user_id: userId, plan });
await admin.from("profiles").update({ onboarded: true }).eq("id", userId);
}
- Warningapp/actions/complete-signup.ts:12
Server Action without auth check
At the top of every Server Action, resolve and verify the current user (e.g. const { user } = await getUser(); if (!user) throw …) and authorize the specific operation before mutating data. If you do guard it with a custom helper, this warning is a false positive.
Every assumption in that function is wrong at once. The userId arrives from the browser, so it is whatever the caller sends — and thanks to the branch above, an attacker can obtain a real one belonging to someone else without knowing their password. The client is built with the service role key, so RLS is not going to save you. And there is no check that the caller is the user they are writing for.
Server actions are public HTTP endpoints; a forged POST reaches them exactly like your form does. Every server action needs its own auth check, and the identity must come from the session on the server, never from an argument:
"use server";
import { createClient } from "@/lib/supabase/server";
export async function completeSignup(plan: string) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error("Not authenticated");
// user.id comes from a verified token, not from the caller.
await supabase.from("subscriptions").insert({ user_id: user.id, plan });
}
A 60-second self-check
# 1. Anywhere you branch on the user object instead of the session
grep -rn "data.user" app/ components/ --include=*.tsx --include=*.ts
# 2. Server actions that take a user id as a parameter
grep -rlZ "use server" app/ | xargs -0 grep -ln "userId\|user_id"
# 3. Sensitive fields being written into user_metadata at signup
grep -rnE "options:\s*\{\s*data:" app/ components/ --include=*.tsx
Hits on 1 are worth reading one by one; hits on 2 need the identity to come from getUser() instead. GuardLayer reports the third shape in the list above automatically — the scan output is the real result for that server action, flagged as a mutation with no detectable auth guard. What it cannot do is tell you the userId came from a hostile signup response; that is runtime data flow, and it is why the self-check above is worth five minutes of your own eyes.
FAQ
Why does Supabase signUp return a user object instead of "email already registered"? For a confirmed address it deliberately returns an obfuscated fake user to prevent enumeration. For an unconfirmed address it does something different: it skips the duplicate check entirely, re-sends the confirmation, and returns the existing account's real row.
Does signUp validate the password against the existing account?
No. signUp is not a sign-in; there is no password comparison on that path. Any string is accepted, and the stored password is left unchanged — GoTrue explicitly refuses to update the row because it cannot verify the caller's identity.
Is the attacker logged in afterwards?
No. With email confirmation enabled, no session is issued for an unconfirmed user, so they hold data, not access. It becomes access if your app treats data.user as proof of authentication, or passes its id into privileged server code.
How do I detect this case in my own code?
Check data.session, not data.user. A null session with a returned user means "confirmation pending", which covers both a genuine new signup and this one — and both deserve the same neutral "check your inbox" message.
Is this fixed? Not at the time of writing. supabase/supabase#33325 has been open since February 2025 and was last updated in September 2026. Check its status before relying on anything here.
Can GuardLayer detect this? Not the Auth response — that is hosted GoTrue behaviour outside your repository. It does flag the code that makes it dangerous: server actions that mutate data with no auth guard, the service role key reaching the client, and tables without RLS behind them.
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.