Skip to content

Password Hashing in Node.js

Storing passwords safely is one of the most important security responsibilities of any application. This lesson covers why plain hashing isn't enough, and how to hash passwords correctly using bcrypt.

Why Plain Hashing Isn't Enough

Simply running a password through crypto.createHash('sha256') is fast, which is exactly the problem: attackers with a leaked password database can try billions of guesses per second against a fast hash. Password-hashing algorithms like bcrypt and argon2 are deliberately slow and include a random "salt" automatically, making large-scale guessing attacks impractical.

bcrypt also protects against "rainbow table" attacks, precomputed hash lookups, because each password is hashed together with a unique, randomly generated salt, so identical passwords produce completely different hashes.

import bcrypt from 'bcrypt';

const password = 'correct horse battery staple';

const hash = await bcrypt.hash(password, 12); // 12 = cost factor
console.log('Stored hash:', hash);

const isValid = await bcrypt.compare(password, hash);
console.log('Password matches:', isValid); // true

The cost factor (12 here) controls how slow the hashing is, higher values are more secure but take longer to compute, both at signup and at login.

bcrypt API

bcrypt.hash(password, saltRounds)     // returns a hash (includes the salt)
bcrypt.compare(password, hash)         // returns true/false
bcrypt.hashSync(password, saltRounds)   // synchronous variant
  • bcrypt.hash() generates a unique salt automatically and embeds it in the returned hash string.
  • bcrypt.compare() extracts the salt from the stored hash and re-hashes the input to compare safely.
  • Never store or compare plain-text passwords, only ever store the resulting hash.
  • A cost factor between 10 and 12 is a common, reasonable starting point as of current hardware.

Password Hashing Cheatsheet

The core do's and don'ts of storing passwords.

Do Don't
Use bcrypt/argon2 to hash passwords Use crypto.createHash('sha256') alone
Let the library generate the salt Reuse the same salt for every password
Store only the resulting hash Store plain-text passwords anywhere, ever
Use bcrypt.compare() to check logins Compare hashes with === manually
Increase cost factor as hardware improves Leave the cost factor fixed forever

Hashing at Signup, Comparing at Login

The password itself is only ever needed briefly, hash it immediately at signup and discard the plain-text value; at login, compare the submitted password against the stored hash without ever decrypting anything (hashing is one-way, there's nothing to decrypt).

// Signup
app.post('/signup', async (req, res) => {
  const passwordHash = await bcrypt.hash(req.body.password, 12);
  const user = await db.users.create({ email: req.body.email, passwordHash });
  res.status(201).json({ id: user.id, email: user.email });
});

// Login
app.post('/login', async (req, res) => {
  const user = await db.users.findByEmail(req.body.email);
  const valid = user && await bcrypt.compare(req.body.password, user.passwordHash);

  if (!valid) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  res.json({ message: 'Logged in' });
});

Returning the exact same generic "Invalid credentials" message whether the email or the password was wrong avoids leaking which emails are registered.

Choosing a Cost Factor

bcrypt's cost factor exponentially increases how long hashing takes: each increment roughly doubles the computation time. Choose a value that takes on the order of 100-300ms on your production hardware, slow enough to deter brute-force attempts, fast enough not to hurt legitimate login latency noticeably.

Common Mistakes

  • Hashing passwords with a fast, general-purpose hash function instead of bcrypt/argon2.
  • Storing or logging plain-text passwords anywhere, even temporarily, during debugging.
  • Comparing password hashes with === instead of the library's dedicated .compare() function.
  • Revealing whether an email exists in the system through different error messages for bad email vs bad password.

Key Takeaways

  • Passwords must be hashed with a slow, salted algorithm like bcrypt, never a fast general-purpose hash.
  • bcrypt.hash() embeds a unique salt automatically; bcrypt.compare() handles comparison safely.
  • Plain-text passwords should exist only transiently, at signup and login, never stored anywhere.
  • A cost factor around 10-12 is a reasonable default, tuned to your hardware and security requirements.

Pro Tip

Never build your own password-hashing scheme, even a seemingly clever one. Stick to bcrypt or argon2, both are battle-tested, peer-reviewed, and handle salting correctly by default, which is far more valuable than any custom approach.