Supabase "Invalid login credentials": 3 real causes
AuthApiError: Invalid login credentials is one message covering three different failures: no account exists for that email, the account exists but has no password (it was created with OAuth or a magic link), or the password is wrong. Supabase makes them identical on purpose, so a login form can't be used to discover who has an account. Tell them apart from your side — the database and the Auth logs — and never by adding an endpoint that tells the browser which one happened.
This isn't a guess about Supabase's behaviour; it's in the source. In the Auth server's internal/api/token.go, the constant is defined once — const InvalidLoginMessage = "Invalid login credentials" — and returned from three separate branches of the password grant: when the user isn't found, when the user has no password, and when the password doesn't match. All three come back as HTTP 400 with the same error code, invalid_credentials.
So if you've been staring at a password you know is correct, stop debugging the password.
Why does Supabase say "Invalid login credentials" when the password is right?
Because one of the other two branches fired. The password was never checked — either there's no account under that address, or the account has no password to check against.
The fastest way to find out which is to look, as a privileged user, at the row the login was aimed at:
select u.id,
u.email,
u.email_confirmed_at,
coalesce(u.encrypted_password, '') <> '' as has_password,
array_agg(i.provider) as providers
from auth.users u
left join auth.identities i on i.user_id = u.id
where lower(u.email) = lower('person@example.com')
group by u.id;
Run it in the SQL editor and read the result like this:
| What you see | Which branch fired | What to do |
|---|---|---|
| No rows | No such user | Wrong project, wrong environment, or a typo in the address |
has_password is false, providers like google | Account has no password | The user signed up with OAuth or a magic link; send them there, or have them set a password via reset |
has_password is true | Password mismatch | Genuinely wrong password — or the password field got trimmed, autofilled or pasted with whitespace |
The "no rows" case catches developers more than users. The classic version: you signed up against your local supabase start instance, then logged in against the hosted project, or vice versa. Both are valid Supabase projects, both return the same message, and only one has your account. Check that the URL your client is using matches the project you just queried.
An unconfirmed email is not one of the three. That path has its own message, Email not confirmed, so if you're seeing Invalid login credentials you can rule confirmation out.
Why the message is identical on purpose
A login endpoint that says "no account with that email" is an account-existence oracle. Point a script at it with a leaked address list and it hands back exactly the subset of people who use your product — the list that makes credential stuffing and targeted phishing cheap.
Supabase refuses to draw that distinction, the same way it returns a fake user when you sign up with an existing email. Developers experience both as bugs. Both are the platform declining to leak information on your behalf.
The workaround that undoes it
The fix people reach for is an endpoint that answers the question Supabase wouldn't:
- Warningapp/api/check-email/route.ts:6
API route without input validation
Validate the parsed body against a schema (e.g. zod safeParse) before use, and return 400 on failure. Consider rate limiting for unauthenticated routes.
The scanner flags the unvalidated request body, and the rest of the route is worse than that finding:
- It's unauthenticated — the whole point is to run before login.
- It runs with the service role key, so it reads every user in the project.
- It returns a clean boolean for any address you POST, as fast as you can POST.
- None of Supabase's auth rate limits apply to it, because the request never touches Supabase Auth's endpoints — it's your route calling the Admin API.
You've taken an endpoint Supabase deliberately made uninformative and put an informative one next to it, with no throttling. The same applies to a security definer RPC that checks auth.users, or a public profiles read policy used for the same lookup. Different packaging, same oracle.
It already leaks — don't spend what's left
Here's the detail that makes the workaround worse. The uniform message isn't a complete defence even as shipped.
In supabase/auth#2674, opened August 2026 and still open, a reporter measured the password grant for an existing address with a wrong password against an unknown address: +74.4 ms, +79.7 ms, +78.9 ms and +77.0 ms, against a control of +1.9 ms comparing two nonexistent addresses. The not-found branch returns before any password hash is computed, so real accounts are measurably slower to reject.
A fix, PR #2693, runs a dummy hash comparison on the not-found and no-password branches to equalise the timing. At the time of writing it is open and unmerged.
So the honest picture is: a determined attacker with a careful timing setup can already distinguish accounts, slowly and noisily. That is not a reason to add an endpoint that does it instantly and reliably. It's a reason to stop handing out easier versions of the same answer.
What to show users instead
One message, every time, with every escape route visible regardless of which branch fired:
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error?.code === "invalid_credentials") {
setMessage(
"Email or password is incorrect. If you signed up with Google or a magic link, use that option — or reset your password."
);
} else if (error) {
setMessage(error.message);
}
The user who forgot they signed up with Google sees the Google hint. The user with a typo sees the reset link. The script probing your login form learns nothing it couldn't learn from any other address. Branch on error.code rather than the message text so a copy change on Supabase's side can't break it.
If you want fewer support tickets, turn on leaked password protection and offer the passwordless option prominently — both reduce wrong-password logins far more than a more specific error ever would.
Quick self-check
Look for anything in your app that answers "does this account exist?" to an unauthenticated caller:
grep -rnE "auth\.admin\.listUsers|auth\.admin\.getUserById|auth\.users" app lib src
Then check the database for callable functions that read auth.users:
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 p.prokind = 'f'
and pg_get_functiondef(p.oid) ilike '%auth.users%'
and has_function_privilege('anon', p.oid, 'EXECUTE');
Every row there is a function any visitor can call with your publishable key. If it takes an email and returns anything that varies with whether the account exists, it's the oracle this post is about.
FAQ
How do I know if the email or the password is wrong in Supabase?
Not from the client — by design. Query auth.users and auth.identities in the SQL editor, or check Auth logs, to see whether the account exists and whether it has a password.
Why can't I get an error code that distinguishes them?
Because every branch returns the same invalid_credentials code. Supabase has declined requests to split them on security grounds; that information is exactly what an attacker wants.
I just signed up and immediately get Invalid login credentials. Why?
Most often a different project or environment than the one you signed up in. If the account exists and is unconfirmed, you'd see Email not confirmed instead.
Is the timing leak something I can fix in my app? Not for direct calls. The browser talks to Supabase Auth with your publishable key, so the response timing is Supabase's. Watch PR #2693; until then, just don't add a faster oracle beside it.
Is it safe to check if a user exists on the server, then? Only when the caller is already authenticated and authorised to know — an admin inviting a teammate, for example. An unauthenticated existence check is the vulnerability, wherever it runs.
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.