Hashes, passwords, and MACs

Hash passwords slowly

Store password verifiers with Argon2id or another current password-hashing construction instead of fast hashes or reversible encryption.

Passwords have a special threat. Once an attacker steals the database, they can guess offline. No rate limit, no lockout, no logs. Just their hardware against your hashing choice.

That is why fast hashes fail here. Say the stolen user table holds unsalted SHA-256 password hashes. The attacker tests billions of common guesses per second, without ever contacting your application. SHA-256 was designed to be fast, and here speed works for the attacker.

Encrypting passwords is also wrong. Whoever holds the key can recover every password. And you never need the original password back. You only need to check a guess against a verifier, a stored value that lets you confirm a password without storing it.

Use a password-hashing function

Use a maintained password library with current OWASP parameters. The function should be deliberately expensive in both time and memory.

Argon2id is the first recommendation today. bcrypt and scrypt are still acceptable. Memory hardness is why Argon2id wins for new systems: GPUs are great at parallel computation but have limited memory per core, so a memory-hungry function blunts them.

Let’s hash and verify a password with the argon2 package:

import argon2 from 'argon2'

const verifier = await argon2.hash('correct horse battery staple')
// $argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$hashedbytes...

await argon2.verify(verifier, 'correct horse battery staple') // true
await argon2.verify(verifier, 'wrong guess')                  // false

Store that encoded string and nothing else. It already carries the algorithm, the parameters, the salt, and the result. Verification years from now will know exactly what to recompute.

OWASP’s current baseline for Argon2id is at least 19 MiB of memory, an iteration count of 2, and one degree of parallelism. Treat that as a floor, not a target.

Tune the cost on real hardware

Raising the cost makes every guess more expensive for the attacker. It also makes every login more expensive for you. Push it too far and a burst of logins takes down the server.

So measure on hardware that looks like production:

console.time('hash')
await argon2.hash('benchmark-password', { memoryCost: 65536, timeCost: 3 })
console.timeEnd('hash')
// hash: 92ms

Somewhere between 50ms and a few hundred milliseconds per hash is the usual budget. Remember that logins happen at the same time: 20 concurrent logins at 64 MiB each is over a gigabyte of memory. Keep your request-level abuse controls in place too. Slow hashing protects stolen data, not your login endpoint.

Try this on your own project: benchmark Argon2id with the current OWASP parameters on production-like hardware, and write down the latency and memory you settled on. Verify a known password and reject a wrong one. Then run several hashes at once and check that the cost you picked stays inside the service’s resource budget.

Lesson completed