Password Hashing Done Right
Use argon2id (or bcrypt), tune the cost to your hardware, and always compare in constant time.
Passwords must be stored as slow, salted hashes — never plaintext, never a general-purpose fast hash like SHA-256 (which a GPU can brute-force billions of times per second). The modern default is argon2id; bcrypt remains a perfectly respectable, battle-tested choice.
Why these algorithms
They are deliberately slow and, in Argon2's case, memory-hard, which neutralizes GPU and ASIC attacks. Each hash embeds its own random salt, so identical passwords produce different hashes and rainbow tables are useless.
import argon2 from 'argon2';
const hash = await argon2.hash(password, { type: argon2.argon2id });
const ok = await argon2.verify(hash, attempt); // constant-time insideTune the cost
Pick parameters so a single hash takes roughly 200–500ms on your production hardware — slow enough to frustrate attackers, fast enough for users. Re-benchmark when you upgrade servers.
Example
import argon2 from 'argon2';
// Cost params tuned to ~250ms on this hardware. Re-measure after upgrades.
const HASH_OPTS = {
type: argon2.argon2id,
memoryCost: 19_456, // 19 MiB
timeCost: 2,
parallelism: 1,
};
export async function register(req, res) {
const { email, password } = req.body;
const passwordHash = await argon2.hash(password, HASH_OPTS);
// store passwordHash — the salt and params are embedded in the string
res.status(201).json({ email });
}
const DUMMY_HASH = await argon2.hash('dummy-for-timing', HASH_OPTS);
export async function login(req, res) {
const user = await userRepo.findByEmail(req.body.email);
// Verify even when the user is missing, against a dummy hash, so response
// time doesn't reveal whether the email exists (user enumeration defense).
const stored = user?.passwordHash ?? DUMMY_HASH;
const ok = await argon2.verify(stored, req.body.password); // constant-time
if (!user || !ok) return res.status(401).json({ error: 'Invalid credentials' });
// Opportunistically upgrade the hash if our cost target has risen.
if (argon2.needsRehash(stored, HASH_OPTS)) {
await userRepo.updateHash(user.id, await argon2.hash(req.body.password, HASH_OPTS));
}
res.json({ token: issueAccessToken(user) });
}When to use it
- A registration endpoint hashes the user's password with bcrypt before storing it in the database so plaintext passwords never touch disk.
- A login endpoint uses bcrypt.compare() to check the submitted password against the stored hash without ever decrypting the hash.
- A developer increases the bcrypt cost factor from 10 to 12 when upgrading the server hardware to maintain adequate brute-force resistance.
More examples
Hash password on registration
Hashes the password with 12 salt rounds before insertion so the database never stores plaintext credentials.
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12;
app.post('/register', async (req, res, next) => {
try {
const { email, password } = req.body;
const hash = await bcrypt.hash(password, SALT_ROUNDS);
await db.users.insert({ email, password: hash });
res.status(201).json({ registered: true });
} catch (err) { next(err); }
});Verify password on login
Fetches the user by email, then uses bcrypt.compare() to safely check the submitted password against the stored hash.
const bcrypt = require('bcrypt');
app.post('/login', async (req, res, next) => {
try {
const user = await db.users.findByEmail(req.body.email);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const valid = await bcrypt.compare(req.body.password, user.password);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
res.json({ ok: true, userId: user.id });
} catch (err) { next(err); }
});Re-hash on cost factor upgrade
After a successful login, checks the current salt rounds and transparently re-hashes with a higher cost factor if needed.
const bcrypt = require('bcrypt');
const NEW_ROUNDS = 14;
async function loginAndUpgrade(email, password) {
const user = await db.users.findByEmail(email);
const valid = await bcrypt.compare(password, user.password);
if (!valid) throw new Error('Invalid credentials');
const currentRounds = bcrypt.getRounds(user.password);
if (currentRounds < NEW_ROUNDS) {
const newHash = await bcrypt.hash(password, NEW_ROUNDS);
await db.users.updateHash(user.id, newHash);
}
return user;
}
Discussion