Supabase Auth · 400
Invalid login credentials — why the message is deliberately vague
Luca Urti
Invalid login credentials
Why does Supabase return “Invalid login credentials” for a user that exists?
Supabase returns a single message for every failed password sign-in, and it does not distinguish an unknown email from a wrong password. That is deliberate, not unhelpful: an error that told you which one it was would let anyone check whether an address has an account. The causes worth checking, in order, are an unconfirmed email address when confirmation is required, a user who signed up through an OAuth provider and therefore has no password set, a password reset that never completed, and the ordinary case of a wrong password. It is not a key problem or a configuration problem — a bad anon key raises a different error entirely.
The causes that are not a wrong password
A user created through Google or GitHub sign-in has an identity but no password credential. Attempting a password sign-in for that address fails and always will, until a password is set through a reset flow. This is the one that generates support tickets, because the user is certain the account exists — and they are right.
If email confirmation is required and the address was never confirmed, the sign-in is refused too. Check that before assuming a password problem, because the fix is resending a confirmation rather than a reset.
Do not add a “does this account exist” check
The natural product improvement is to tell the user which of the two went wrong, and it requires an endpoint that answers whether an email is registered. That endpoint is a user enumeration oracle: run a list of addresses through it and you learn who has an account, which is a privacy breach on its own and the first step in credential stuffing.
If you want to reduce the confusion, do it without answering the question — mention on the form that accounts created with Google have no password, and make the reset flow easy to reach. Both help the honest user and tell an attacker nothing.
The fix
handle it without leaking which half failed
export async function signIn(email: string, password: string) {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
// One branch, one message. Do not look the address up to say more.
return showError(
"That email and password do not match. If you signed up with Google, " +
"use the Google button — or reset your password to set one.",
);
}
return data.user;
// NOT this — it answers "does this account exist?" for anyone who asks:
// const exists = await fetch('/api/user-exists?email=' + email);
// if (!exists) return showError("No account with that email");
}The fix that works and costs you the database
The workaround people reach for is a server route that looks the address up with the service_role key so the form can say “no account with that email”. It ships an unauthenticated endpoint that confirms whether any given address is registered, usually with no rate limit, backed by a key that can read every table in the project. The second version is disabling email confirmation to stop the error appearing at all — which lets anyone register an address they do not control and receive a working account for it.
Whether the trap is already in your repo is a question you can answer
Sentris reads the SQL and the client code, so it reports the shortcut above where it was actually taken — a service_role key in a browser bundle, a policy that is using (true), a table with RLS switched off. A scan needs no account and no card. How often it is wrong is measured and published.