Authentication

Store passwords so a stolen database is useless, and let a request prove who it is.

11of 18
≈ 14Min read
7Parts

Passwords are never stored

  • Never store the password. Not encrypted either — encryption can be reversed with the key, and the key is on the same server.
  • Never use SHA-256 or MD5. They are fast, and fast is the enemy: a modern GPU tries billions per second.
  • Use bcrypt, scrypt or argon2id. They are deliberately slow and have a cost factor you raise as hardware improves.
  • The salt is generated per user and stored with the hash — bcrypt puts it inside the string. It is what stops one precomputed table breaking every account at once.
hash, with a slow algorithm built for it
import bcrypt from "bcrypt";

const hash = await bcrypt.hash(password, 12);     // store this
const ok = await bcrypt.compare(password, user.passwordHash);
Do not write your own

The lab below implements a toy hash so you can see the shape of the code. It is clearly labelled, and it is not safe. In a real project this is one line of a library that has been attacked for twenty years.

Sessions or tokens

For a web app with a browser, a session id in a cookie is simpler and safer, and revocation actually works. Reach for JWTs when something other than a browser needs to carry proof, or when a service cannot reach your session store.

Session cookieJWT
Where the state livesOn the serverIn the token
Revoking itDelete the row — instantHard: it is valid until it expires
Size on every requestA small idThe whole payload
Good forA normal web applicationService-to-service, short-lived access

Cookies, set properly

A token kept in localStorage is readable by any script on the page, which means one XSS is a full account takeover. An httpOnly cookie is not readable at all.

four attributes that matter
res.cookie("sid", sessionId, {
  httpOnly: true,     // JavaScript cannot read it — blunts XSS
  secure: true,       // HTTPS only
  sameSite: "lax",    // not sent on cross-site POSTs — blunts CSRF
  maxAge: 1000 * 60 * 60 * 24 * 7,
  path: "/",
});

What a JWT is

  • The payload is signed, not encrypted. Anyone holding the token can read it — never put anything private in it.
  • The signature proves it was made by someone with the secret, and that nothing has been changed.
  • Always check exp, and always check the algorithm. Accepting alg: "none" was a real vulnerability in real libraries.
  • Keep them short-lived (minutes), with a refresh token that can be revoked.
three base64 parts, and only the third is secret
header.payload.signature

{ "alg": "HS256" }.{ "sub": "12", "exp": 1760000000 }.HMAC(header.payload, secret)

The login endpoint

  • Rate-limit it. Five attempts a minute per address stops the simplest credential stuffing.
  • Compare in constant time — bcrypt.compare already does.
  • Log the attempt, not the password.
small details, real consequences
app.post("/login", async (req, res) => {
  const user = await users.byEmail(req.body.email);

  // the same answer either way: never reveal which accounts exist
  const ok = user && await bcrypt.compare(req.body.password, user.passwordHash);
  if (!ok) return res.status(401).json({ error: "Invalid email or password" });

  req.session.userId = user.id;          // or issue a token
  res.json({ id: user.id, email: user.email });   // never the hash
});

Protecting a route

401 means "I do not know who you are"; 403 means "I know, and you may not". Mixing them up sends a signed-in user to the login page forever.

one middleware, used everywhere
function requireUser(req, res, next) {
  const header = req.headers.authorization || "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: "Not signed in" });

  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch {
    res.status(401).json({ error: "Not signed in" });
  }
}

app.get("/me", requireUser, (req, res) => res.json(req.user));

The rest of it

And consider not building it at all. Auth0, Clerk, Supabase Auth and Lucia exist because this is a lot of surface area to own — most of it invisible until it fails.

  • Password reset: a single-use token with a short expiry, stored hashed, invalidated on use.
  • Email verification before anything that sends mail on the user’s behalf.
  • Two-factor: TOTP is a few lines with a library and stops almost all account takeover.
  • OAuth ("Sign in with…"): let someone else hold the passwords when you can.

Practice