Skip to content

Security basics

"Invalid login credentials" in Supabase: the three causes

Supabase says "Invalid login credentials" for a wrong password, an unknown email and a Google account. How to tell which, and why it is vague.

Vlad Tkachenko11 min read
A door keypad with its one lamp lit red, and in front of it a badge the keypad has no slot for.

In short

  • Supabase answers "Invalid login credentials" for three different people: someone who typed the wrong password, someone whose email has no account, and someone who signed up with Google and has never had a password.
  • It does this on purpose. A sign-in form that said which of the three it was would tell anyone holding a list of email addresses which of them belong to your users.
  • You can tell the three apart yourself with one query on auth.users. Your sign-in page should keep the message vague and offer every way the account could have been made.

Somebody writes in to say they cannot log in to your app, and they are sure the password is right. You try their address yourself and get the same red line under the form:

Invalid login credentials

Supabase returns "Invalid login credentials" for a wrong password. It returns exactly the same words for two other people: someone whose email has no account in your app, and someone who signed up with Google and has never had a password. Your Lovable, Bolt, v0, Cursor, Claude Code or Replit app is working as built. It gives one answer to three different situations.

Here is the part that the obvious fix gets wrong: the vague message is deliberate, and making it more helpful opens a hole. What helps is finding out which of the three you are looking at, which your own dashboard can tell you, while the form goes on saying as little as it does now.

What does "Invalid login credentials" mean in Supabase?

It means Supabase Auth, the part of Supabase that signs people in, refused an email and password and is keeping the reason to itself.

The message is written into the Auth server's own code. In the function that handles a password sign-in, the same error is returned at three points: when no user has that email, when the user has no password stored, and when the password does not match. Each time the reply is HTTP 400 with the code invalid_credentials, which Supabase's error code reference describes as "Login credentials or grant type not recognized."

In your app it arrives as an AuthApiError from signInWithPassword, with error.code set to invalid_credentials. The same reference tells you to "always use error.code and error.name to identify errors", and that matters later, when you decide what your form says. If your users were signed in and then thrown out, that is a different error with a page of its own.

Think of your sign-in form as the keypad beside a building's front door. A visitor types a code and one lamp answers. Green opens the door. Red comes on for a wrong code, for a code that was never given to anybody, and for a resident who has always come in through the side door with a badge and has never had a code at all.

The three people behind the message

Each of the three needs a different fix, so it is worth knowing which one you have in front of you.

The wrong password. The account exists, it has a password, and what they typed does not match it. That includes a password changed on another device and forgotten since, and a password manager filling in an old one.

The address with no account. Nobody in your project signed up with that email. It can be a second address (the work one, when they signed up with a personal one) or a typo made at sign-up that is now stored as their email.

The account with no password. They signed up with Google, GitHub or another sign-in button, and Supabase never stored a password for them. When they come back and type one, there is nothing to compare it with. The Auth server checks that a password is stored before it looks at the one typed in, and an account without one gets the same 400.

People who signed up with a magic link, the sign-in link sent by email, are a variation on the first case. Supabase's magic link code creates their account with a random password that nobody is ever shown, so any password they type is a wrong one.

A wrong password, an unknown address and an account with no password all reach the same check, and all three get the same reply.

How do I tell which one it was?

Ask your own database. Every account in your project is a row in a table called auth.users, and that row says how the person signs in.

Supabase's guide to users describes the field that matters: app_metadata.providers is "the list of providers that the user can use to sign in with". A user "can have multiple identities", so one person may have both a password and Google. Whether a password is stored at all is in the column the server itself checks.

Open the SQL Editor in your Supabase dashboard and run this with the address the person gave you:

select email,
       raw_app_meta_data->'providers' as signs_in_with,
       coalesce(encrypted_password, '') <> '' as has_password,
       email_confirmed_at
from auth.users
where lower(email) = lower('person@example.com');

It reads one row and never shows a password, only whether one exists.

What comes backWhat it means
No rowNo account has that address in this project. Ask which other address they might have used
has_password is false, and signs_in_with lists google, github or similarThey signed up with that button and have never had a password
has_password is trueThe password they typed is wrong, or they signed up with a magic link and never knew theirs
email_confirmed_at is emptyThey have not confirmed their email yet, which becomes a different message once the password is right

If you would rather click than type, Authentication → Users in the dashboard lists the same accounts.

The account that never had a password

Send them back to the button they used, or let them set a password through your reset link.

At the keypad, this is the resident who has always used the side door. There is no code on file for them, so every code they try is refused.

The first repair is on your sign-in page. If sign-up offers "Continue with Google", the sign-in page needs the same button, so somebody who signed up with it sees it when they come back.

If they would like a password as well, your "Forgot password?" link already does it. Supabase's password reset code sends the recovery email to any account it finds without checking how the account was made. A Google user who follows the link and sets a new password can sign in either way afterwards. That depends on your app having the page that sets the new password, which calls supabase.auth.updateUser({ password }). If the reset link lands on a page that never asks for one, the person is signed in once and still has no password.

"Email not confirmed" means the password was right

It is a different message with a different code, and it only reaches somebody who has already passed the password check.

In the same Auth server function, the check for a confirmed email comes after the password check. So Email not confirmed, code email_not_confirmed, only comes back to someone who typed an existing email and its correct password. Supabase's error reference words it as "Signing in is not allowed for this user as the email address is not confirmed." At the keypad it is a different lamp: the code was right, and the door stays shut until the move-in paperwork is done.

You see it when Confirm email is switched on for your project's Email provider. Supabase's configuration guide says users then "will need to confirm their email address before signing in for the first time." The fix is the email they have not clicked yet: tell them to check their inbox and spam folder, and offer to send it again with supabase.auth.resend({ type: 'signup', email }). If those emails are not arriving at all, the email rate limit is the first thing to rule out. If they arrive and the link then says "Email link is invalid or has expired", something probably opened it before they did.

Because this message only appears after a correct password, telling the person to check their inbox gives a stranger nothing they did not already have.

Why the message stays vague

Because the helpful version would tell strangers who your users are.

Picture the keypad answering "nobody lives in flat 4B". Anyone walking past could go down the list of flats and learn which ones are occupied.

Your form works the same way. If it said "No account with that email", anyone holding a list of addresses could type each one in and learn which belong to your users. A breach of some other website leaves a list like that behind, sometimes with the passwords people used there. Once somebody knows which addresses are your users, they know whose old passwords are worth trying on your app, and who is likely to open an email that looks like it came from you.

Supabase applies the same rule wherever an address goes in. Its sign-up code, asked to create an account for an email that is already registered and confirmed, can hand back a made-up user with a random ID, through a function whose comment says it exists to stop "information about whether a user is registered or not from leaking". The password reset answers an unknown address with the same empty success as a known one.

The sign-up side has a switch you control. That made-up user is what comes back when confirmation is required for both email and phone sign-ups. If either one is switched off (the phone one counts even when your app never uses phone sign-in), a registered address gets User already registered, code user_already_exists, and your sign-up form becomes a list-checker of its own.

The helpful message also has to come from somewhere. Supabase will not tell your app whether an email has an account, so to print "No account with that email" your app has to find out by itself. That means a lookup that works for a visitor who has not logged in: a read of your profiles table that only succeeds if anyone can read the emails in it, or a database function that searches auth.users for whoever asks. Either way, the list of your users becomes readable from outside.

The first of those can be seen from outside, which is how our free scan looks for it. It asks each table your app names how many rows a visitor with no login could get, reads only the count, and takes about 20 seconds with no account: scan your app.

A form that names a missing account sorts any list of addresses into your users and everyone else. One answer for everybody leaves the list as it was.

What to put on your sign-in page instead

Keep the message vague, and put every way back in on the same page.

  • One message for all three cases, in plain words, with the other routes beside it: "That email and password do not match. If you signed up with Google, use the button below." It helps the Google user and tells a stranger nothing your sign-up page does not.
  • Every method from your sign-up page. The Google button, the magic link and the password field, all on the sign-in page too.
  • A "Forgot password?" link to a page that sets one. Supabase answers every address the same way there, so it is safe to show, and it is how a person with no password gets one.
  • Decisions on error.code. Show your vague message for invalid_credentials, and "check your inbox" with a resend button for email_not_confirmed. Supabase's error reference says to identify errors by their code rather than by matching the message text, and warns that HTTP status codes "may change unexpectedly".

In code, that is two branches:

const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error?.code === 'email_not_confirmed') showCheckYourInbox(email)
else if (error) showSignInHelp()

If a builder writes your sign-in page, you can paste these four points in as the instruction, including the one about what the page must keep to itself.

Keeping your sign-in page the way you left it

Two things can still go wrong after today: a later prompt rewrites the page to be "more helpful", and a change somewhere else loses the accounts the page signs people into.

Reeve Monitor keeps checking from outside:

  • all nine checks every hour, on up to three apps, including how many rows a visitor with no login can count in the tables your app names
  • an email the day a re-scan grades your app lower than the one before
  • whether the app is up, every 60 seconds
  • a monthly report, in plain language, of what it saw

Reeve Care keeps a copy of your Supabase database, accounts included:

  • an encrypted copy on a schedule, on Reeve's own storage in the EU, outside your Supabase account
  • the auth schema your users' accounts live in, so they come back with everything else
  • each copy verified before it counts, by counting the rows in every table
  • your uploaded files as well, once you connect a Storage credential
  • a one-click restore, with a safety copy of the current state taken first
  • everything Monitor does

What to do now

What to do

  • Run the auth.users query with the address the person gave you, and read which of the three it is.
  • No row: ask which other address they might have signed up with.
  • No password: point them at the Google or GitHub button they used, or at your reset link.
  • A password, and still refused: send them the reset link.
  • Show one vague message for invalid_credentials, and "check your inbox" only for email_not_confirmed.
  • Never add a check that tells a visitor whether an email has an account.

If the trouble starts one step earlier and new accounts are failing to be created, Database error saving new user covers that end of the form. The 10-minute security checklist covers what else tends to be left open in a newly launched app.

FAQ

Why does Supabase say invalid login credentials when the password is right?

Because the password may be right for an account that does not exist, or the account may have no password at all. Check that the email is the one they signed up with, and whether they signed up with Google or another sign-in button, which leaves the account without a password. One query on auth.users in the SQL Editor shows which it is.

Does "Invalid login credentials" mean the account does not exist?

Sometimes. Supabase returns the same message, with the same code, invalid_credentials, when there is no account with that email, when the account has no password and when the password is wrong. The message alone cannot tell you which, and it was built that way.

Why can a user who signed up with Google not log in with a password?

Because their account was created without one. Supabase checks that a password is stored before it compares the one typed in, and an account with none gets "Invalid login credentials". They can keep using the Google button, or use your app's reset-password link to set a password: Supabase sends that email to any account it finds, however it was made.

Should my login form tell users the email is not registered?

No. That message tells anyone who types an address whether it belongs to one of your users, and somebody with a list of leaked addresses can check them all. Your app would also need a lookup that answers the question for strangers, which is a second hole. Keep the message vague and show the other ways to sign in.

What is the difference between "Invalid login credentials" and "Email not confirmed"?

Supabase only returns "Email not confirmed" after the password has been checked and found correct. So the right person typed the right password, and the account is waiting for them to click the link in their confirmation email. It is safe to tell them to check their inbox and to offer to send the email again.

Written by

Vlad Tkachenko

Founder, Reeve

I spend my time looking at apps built with Lovable, Bolt, v0, Cursor and Replit, and at the short list of mistakes that keep turning up in them.

More about the author

Read next

All articles

Back up your Supabase database before your next change

Reeve keeps an encrypted copy on a schedule and restores it for you if something breaks.

See Supabase backups

Automated external check, not a full audit. Absence of findings is not a guarantee of safety.