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.
import bcrypt from "bcrypt";
const hash = await bcrypt.hash(password, 12); // store this
const ok = await bcrypt.compare(password, user.passwordHash);
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 cookie | JWT | |
|---|---|---|
| Where the state lives | On the server | In the token |
| Revoking it | Delete the row — instant | Hard: it is valid until it expires |
| Size on every request | A small id | The whole payload |
| Good for | A normal web application | Service-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.
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. Acceptingalg: "none"was a real vulnerability in real libraries. - Keep them short-lived (minutes), with a refresh token that can be revoked.
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.comparealready does. - Log the attempt, not the password.
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.
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.