Ask Claude Code for a login page and you will get one. It will look right, it will work, and you will have no way to tell whether the passwords behind it are stored in a way that survives someone reading your database. This is the one area of a small app where "it works" and "it is correct" come apart completely.
The idea
AuthenticationProving who someone is, usually by them entering something only they know or control. is two separate questions the browser cannot answer on its own. Who is this person, and are they still that person on the next click? The first is answered once, when they log in. The second is answered on every request by a Proof the browser carries with it that this visitor already logged in, so your app does not have to ask again on every click., which the browser sends back to you with each request. Everything else, including the password handling that will hurt you if you get it wrong, is a solved problem you should be renting.
LOGIN (once) EVERY REQUEST AFTER
------------------------------ ----------------------------------
browser: email + password browser: Cookie: session=a8f3...
| |
v v
server hashes the password server looks up a8f3...
(argon2id / bcrypt, slow on purpose) |
| +--> found, not expired
v | => this is user 1
compare with the stored hash +--> missing or expired
| => 401, show the login page
+--> match: issue a token for this browser
| (in a cookie: HttpOnly; Secure)
+--> no match: same generic error
the database stores: $argon2id$v=19$... never the password itself
two shapes for "still logged in":
session id -> meaningless string, server looks it up
signed JWT -> carries the user id, checked by signature
supabase, clerk and auth0 issue the second one, and where it is
kept (cookie or browser storage) depends on how you set them up.How it works
A A one-way scramble of a value. You can check a guess against it, but you cannot
turn it back into the original. is what you store instead of the password. The algorithms
built for this are deliberately slow and add a random salt per user, so someone holding your whole
users table still has to attack each row separately. OWASP's password storage guidance puts
Argon2id first today and treats bcrypt as acceptable in systems that already use it. Fast general
hashes like SHA-256 are wrong here, and the difference is invisible in testing. This is why
hand-rolling is a mistake even when your code passes every test you write.
Staying logged in is the other half, done one of two ways. The older is an opaque session: the
browser holds a meaningless random string and your server looks it up each request. Supabase, Clerk
and Auth0 issue a
JSON Web TokenA login token that carries the facts about you inside itself,
signed so the server can tell it has not been edited. Anyone holding it can read what is in it.
instead, checked by its signature rather than by a lookup. So it carries nothing you would
not show the user, and it stays valid until it expires even if you delete the account. In either
design, when the token sits in a cookie, HttpOnly stops page JavaScript reading it, Secure stops
it travelling over plain HTTP, and SameSite limits which sites can trigger requests carrying it.
What to do
- Pick a provider and use it. Supabase Auth if you already run Postgres there, Clerk if you want the interface built too, Auth0 for larger organizations. All three handle hashing, login tokens, password reset and email verification. Read the free plan's limit on monthly active users and the price of the next tier first, because moving later is real work. Turn on Google or GitHub sign-in at the same time if it suits your users: it removes password handling from your project entirely, in exchange for registering an application with each provider.
- Find out where your login token is actually kept, because that decides what you protect. In
developer tools, open the Application tab and look under Cookies, then under Local Storage. The
browser-side Supabase client uses local storage by default; a server-rendered setup uses a
cookie. A cookie your own app issued wants
HttpOnlyandSecureticked, both columns in that table. A token in local storage is readable by any script on the page. - Check the thing that matters more: whether the server is really looking. Log out, then open a
logged-in URL such as
/api/orders/42in a private window. If it answers with data, the only thing that was ever protecting that page is a hidden button. - Return the same message for a wrong password and an unknown email. Different messages tell an attacker which addresses are registered. This is the deliberate exception to the rule on the interface page, that a form should say exactly what went wrong. Everywhere else be specific. On the login form, be vague on purpose.
Where it breaks
Renting auth moves the risk without deleting it. If Clerk is down, nobody logs into your app, and you have accepted that. Migrating between providers later is real work: password hashes can often be imported, so your users keep their passwords, but live sessions never move, which means everyone is logged out on the day you switch.
There is also a question authentication never answers. This person is Ana, but may Ana read invoice 4102? That is authorization, it is specific to your product, and no provider can write it for you. It is where the actual data leaks happen, and it is why section 5 gives the five access-control mistakes a section of their own. Every one of them looks completely fine while you are building it.