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 The server's memory that this browser already proved who it belongs to, so it does not have to ask again on every click. that the browser carries back to you as a cookie. 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...
(bcrypt / argon2, 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: create session a8f3...
| Set-Cookie: HttpOnly; Secure
+--> no match: same generic error
the database stores: $2b$12$Km9... never the password itselfHow 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, bcrypt and argon2, are deliberately slow and add a random salt per user, so someone
holding your whole users table still has to attack each row separately. 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.
The session cookie is the other half. It holds a random identifier, not your name and not your
permissions. The HttpOnly flag stops page JavaScript from reading it, Secure stops it travelling
over plain HTTP, and SameSite limits which sites can trigger requests carrying it. On every
request your server turns that cookie back into a user id, or it does not, and "logged in" means
nothing more than that lookup succeeding.
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, session cookies, password reset and email verification.
- Check the two things providers do not decide for you: is the session cookie
HttpOnlyandSecure, and does your server check the session on every protected route rather than hiding the button in the interface. - Turn on Google or GitHub sign-in early if it fits your users. It removes password storage, password reset and most support email from your project entirely.
- Return the same message for a wrong password and an unknown email. Different messages tell an attacker which addresses are registered.
Where it breaks
Renting auth moves the risk, it does not delete it. If Clerk is down, nobody logs into your app, and you have accepted that. Migrating between providers later is real work because sessions and password hashes rarely move cleanly. 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.