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

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 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.

code
  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 itself

How it works

A 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

  1. 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.
  2. Check the two things providers do not decide for you: is the session cookie HttpOnly and Secure, and does your server check the session on every protected route rather than hiding the button in the interface.
  3. 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.
  4. 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.