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

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

  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, 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.
  2. 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 HttpOnly and Secure ticked, both columns in that table. A token in local storage is readable by any script on the page.
  3. Check the thing that matters more: whether the server is really looking. Log out, then open a logged-in URL such as /api/orders/42 in a private window. If it answers with data, the only thing that was ever protecting that page is a hidden button.
  4. 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.