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 inside

Tune 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

Example · javascript
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.

Example · js
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.

Example · js
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.

Example · js
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

  • Be the first to comment on this lesson.