Password Hashing (bcrypt)

Difficulty: Intermediate

Question

How do you securely store and verify passwords in Node.js? Explain hashing, salting, and bcrypt.

Answer

Passwords must never be stored in plain text. Instead, they are hashed using a one-way function that produces a fixed-length digest. Even if the database is compromised, the original passwords cannot be recovered from the hashes.

Bcrypt is the industry standard for password hashing. It automatically generates a random salt (preventing rainbow table attacks), includes the salt in the output, and uses a configurable work factor (salt rounds) that makes brute-force attacks computationally expensive.

The salt rounds parameter (typically 10-12) controls how many iterations the algorithm performs. Each increment doubles the computation time. This means bcrypt automatically becomes harder to crack as hardware gets faster - you just increase the rounds.

Bcrypt's compare function is designed to be constant-time, preventing timing attacks where an attacker measures response times to guess password characters. Always use bcrypt.compare() instead of comparing hashes with === directly.

Code examples

Hashing and Verifying Passwords

const bcrypt = require('bcrypt');

const SALT_ROUNDS = 12; // ~300ms on modern hardware

// Hash a password
async function hashPassword(plainPassword) {
  const hash = await bcrypt.hash(plainPassword, SALT_ROUNDS);
  return hash;
  // Output: $2b$12$LJ3m4ys3Lg...  (60 chars)
  // Format: $algorithm$rounds$salt+hash
}

// Verify a password
async function verifyPassword(plainPassword, storedHash) {
  const isMatch = await bcrypt.compare(plainPassword, storedHash);
  return isMatch;
}

// Usage
const hash = await hashPassword('MySecurePass123');
console.log('Hash:', hash);
console.log('Match:', await verifyPassword('MySecurePass123', hash));
console.log('No match:', await verifyPassword('wrong', hash));

bcrypt.hash() generates a unique salt each time, so the same password produces different hashes. bcrypt.compare() extracts the salt from the stored hash to verify.

Registration and Login Flow

const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12;

// Registration
app.post('/api/auth/register', async (req, res) => {
  const { email, password, name } = req.body;

  // Check if user exists
  const existing = await prisma.user.findUnique({ where: { email } });
  if (existing) {
    return res.status(409).json({ error: 'Email already registered' });
  }

  // Hash password before storing
  const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);

  const user = await prisma.user.create({
    data: { email, password: hashedPassword, name },
  });

  res.status(201).json({ id: user.id, email: user.email });
});

// Login
app.post('/api/auth/login', async (req, res) => {
  const { email, password } = req.body;

  const user = await prisma.user.findUnique({ where: { email } });

  // Same error for both cases (prevents email enumeration)
  if (!user || !await bcrypt.compare(password, user.password)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const token = generateToken(user);
  res.json({ token, user: { id: user.id, email: user.email } });
});

Return the same error for missing user and wrong password to prevent email enumeration attacks. Hash on register, compare on login.

Why Not Use Plain Crypto Hashing

const crypto = require('crypto');
const bcrypt = require('bcrypt');

// BAD: Simple hash (fast = easy to brute force)
const md5 = crypto.createHash('md5').update('password').digest('hex');
// 5f4dcc3b5aa765d61d8327deb882cf99
// Can be cracked in milliseconds with rainbow tables

// BAD: SHA256 (still too fast for passwords)
const sha = crypto.createHash('sha256').update('password').digest('hex');
// Billions of hashes per second on GPU

// GOOD: bcrypt (intentionally slow)
const hash = await bcrypt.hash('password', 12);
// ~300ms per hash - makes brute force impractical

// Salt comparison
const hash1 = await bcrypt.hash('password', 12);
const hash2 = await bcrypt.hash('password', 12);
console.log(hash1 === hash2); // false! Different salts
console.log(await bcrypt.compare('password', hash1)); // true
console.log(await bcrypt.compare('password', hash2)); // true

MD5/SHA are designed to be fast - bad for passwords. bcrypt is intentionally slow and uses unique salts, so identical passwords produce different hashes.

Key points

Concepts covered

bcrypt, Salt, Hashing, Password Security, Timing Attacks