Password Hashing covers the hashing itself — argon2id at 64 MiB, t=3, p=4, or bcrypt at cost 12, never a bare SHA-256. What is left for Express 24,430 is the routes around it, and that is where the mistakes live.
const OPTS = { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 4 };
const DUMMY = await argon2.hash('never-matches', OPTS); // npm i argon2@0.45.1
app.post('/register', async (req, res) => {
const { email, password } = req.body;
if (typeof password !== 'string' || password.length < 12 || password.length > 128) {
return res.status(400).json({ error: 'password_policy' });
}
await users.create({ email: email.trim().toLowerCase(), // a unique index
hash: await argon2.hash(password, OPTS) }).catch(() => null); // decides the race
res.status(202).json({ status: 'check_your_email' }); // same body either way
});
app.post('/login', async (req, res) => {
const user = await users.findByEmail(String(req.body.email ?? '').toLowerCase());
const ok = await argon2.verify(user?.hash ?? DUMMY, String(req.body.password ?? ''));
if (!user || !ok) return res.status(401).json({ error: 'invalid_credentials' });
startSession(req, res, user); // regenerate, then store the id: Section 3.6.3
});Three details carry their weight. Verifying against DUMMY when the account does not exist keeps the response time constant, so nobody can enumerate valid addresses with a stopwatch; the identical 401 body stops them enumerating by response text. And capping length at 128 characters stops someone posting a one-megabyte password and pinning a CPU core, since argon2's cost grows with input.
Length beats character classes: NIST SP 800-63B dropped mandatory composition rules and periodic expiry, asking instead for a decent minimum length and a check against breached passwords, which zxcvbn-ts or the k-anonymity range API from Have I Been Pwned will do. Rate limiting is not optional either — apply express-rate-limit 3,306 (Rate Limiting) per IP and per account.