Node.jsBackendFull-Stack

Password Hashing with bcrypt in Node.js

TT
TopicTrick Team
Password Hashing with bcrypt in Node.js

Password Hashing with bcrypt in Node.js

Storing passwords is one of the highest-stakes operations in any application. A database breach is a matter of when, not if, for most internet-facing services. If passwords are stored as plain text or with a fast hash like MD5, a breach exposes every user's password instantly. If they are stored with bcrypt, an attacker faces years of computation to crack even a small fraction of them.

This module covers everything you need to store passwords safely: how bcrypt works, how to choose the cost factor, how to hash and verify correctly, and how to avoid the mistakes that undermine otherwise good implementations.

This is Module 19 of the Node.js Full‑Stack Developer course.


How bcrypt Works

bcrypt has three properties that make it suitable for passwords:

1. Adaptive cost. The work factor (salt rounds) controls how many iterations bcrypt performs. Doubling the rounds doubles the computation time. As hardware gets faster, you increase the rounds to keep cracking infeasible.

2. Built-in salt. bcrypt generates a cryptographically random 128-bit salt for every password automatically. The salt is embedded in the output hash, so you never store it separately. Two users with identical passwords get completely different hashes.

3. Fixed-length output. bcrypt always produces a 60-character string regardless of input length. You can store it in a VARCHAR(60) column.

A bcrypt hash looks like this:

text
$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewdBPj/o.XKzQn5G
│   │  │                    │
│   │  └── Salt (22 chars)  └── Hash (31 chars)
│   └── Cost factor (12 = 2^12 = 4096 iterations)
└── Algorithm version (2b = current)

Installing bcrypt

bash
npm install bcrypt

bcrypt has a native C++ addon for performance. If you need a pure JavaScript fallback (e.g., serverless environments with limited native support):

bash
npm install bcryptjs   # pure JS, ~30% slower, no native compilation

The API is identical — just change the import.


Choosing Salt Rounds

Run this benchmark on your production hardware to pick the right value:

js
// scripts/benchmark-bcrypt.js
import bcrypt from 'bcrypt';

async function benchmark() {
  const password = 'benchmark-password';
  console.log('Rounds | Time (ms)');
  console.log('-------|----------');

  for (let rounds = 8; rounds <= 14; rounds++) {
    const start = Date.now();
    await bcrypt.hash(password, rounds);
    const ms = Date.now() - start;
    console.log(`${rounds.toString().padStart(6)} | ${ms}`);
  }
}

benchmark();

Example output on a typical VPS:

text
Rounds | Time (ms)
-------|----------
     8 |        20
     9 |        40
    10 |        80
    11 |       160
    12 |       320
    13 |       640
    14 |      1280

A value of 12 is a good production default for 2025+ hardware. It gives ~320ms per hash — slow enough to resist cracking, fast enough that login feels instant to users.


Hashing a Password

js
// lib/crypto.js
import bcrypt from 'bcrypt';

const SALT_ROUNDS = parseInt(process.env.BCRYPT_ROUNDS ?? '12');

/**
 * Hash a plain-text password.
 * Always async — never use bcrypt.hashSync() in a server.
 */
export async function hashPassword(plain) {
  return bcrypt.hash(plain, SALT_ROUNDS);
}

/**
 * Verify a plain-text password against a stored hash.
 * Uses constant-time comparison internally — safe against timing attacks.
 */
export async function verifyPassword(plain, hash) {
  return bcrypt.compare(plain, hash);
}
text
# .env
BCRYPT_ROUNDS=12

Using hashPassword in a Service

js
// features/users/users.service.js
import { User } from './users.model.js';
import { hashPassword, verifyPassword } from '../../lib/crypto.js';
import { AppError } from '../../lib/errors.js';

export async function createUser({ name, email, password }) {
  const exists = await User.findOne({ email });
  if (exists) throw new AppError('Email already registered', 409);

  const hashed = await hashPassword(password);
  const user = await User.create({ name, email, password: hashed });

  // Never return the password field
  const { password: _, ...safe } = user.toObject();
  return safe;
}

export async function verifyCredentials(email, password) {
  // Always select password explicitly (it has select: false in schema)
  const user = await User.findOne({ email }).select('+password');

  // Check user existence and password in constant time
  // (don't return early on "user not found" — that leaks information)
  const valid = user ? await verifyPassword(password, user.password) : false;

  if (!user || !valid) {
    throw new AppError('Invalid email or password', 401);
  }

  const { password: _, ...safe } = user.toObject();
  return safe;
}

export async function changePassword(userId, { currentPassword, newPassword }) {
  const user = await User.findById(userId).select('+password');
  if (!user) throw new AppError('User not found', 404);

  const valid = await verifyPassword(currentPassword, user.password);
  if (!valid) throw new AppError('Current password is incorrect', 401);

  user.password = await hashPassword(newPassword);
  await user.save();
}

Common Mistakes

❌ Comparing Hashes Directly

js
// WRONG — hash output changes every time due to random salt
const hash1 = await bcrypt.hash('mypassword', 12);
const hash2 = await bcrypt.hash('mypassword', 12);
console.log(hash1 === hash2); // false — never do this!

// CORRECT — always use bcrypt.compare()
const match = await bcrypt.compare('mypassword', hash1);
console.log(match); // true

❌ Double-Hashing

js
// WRONG — hashing before storing, then hashing again on login
// The stored hash is correct, but the login hash of the hash never matches
const stored = await bcrypt.hash(password, 12);
const loginAttempt = await bcrypt.hash(password, 12); // different salt!
await bcrypt.compare(loginAttempt, stored); // always false

❌ Using bcrypt.hashSync() in a Route Handler

js
// WRONG — blocks the event loop for 300ms+ on every request
app.post('/login', (req, res) => {
  const hash = bcrypt.hashSync(req.body.password, 12); // blocks everything
});

// CORRECT
app.post('/login', async (req, res, next) => {
  try {
    const hash = await bcrypt.hash(req.body.password, 12);
    // ...
  } catch (err) {
    next(err);
  }
});

❌ Returning Early on User Not Found

js
// WRONG — leaks whether the email exists via response timing
const user = await User.findOne({ email });
if (!user) return res.status(401).json({ message: 'Invalid credentials' }); // fast
await bcrypt.compare(password, user.password); // slow
// An attacker can tell if the email exists by measuring response time

// CORRECT — always run bcrypt.compare, even for non-existent users
const user = await User.findOne({ email }).select('+password');
const valid = user
  ? await bcrypt.compare(password, user.password)
  : await bcrypt.compare(password, '$2b$12$invalidhashpadding0000000000000000000000000000000000000');
if (!user || !valid) throw new AppError('Invalid email or password', 401);

Password Validation Before Hashing

Validate password requirements before hashing — bcrypt silently truncates passwords longer than 72 bytes:

js
// features/auth/auth.schema.js
import Joi from 'joi';

export const registerSchema = Joi.object({
  name: Joi.string().min(2).max(100).required(),
  email: Joi.string().email().required(),
  password: Joi.string()
    .min(8)
    .max(72)                     // bcrypt truncates at 72 bytes
    .pattern(/[A-Z]/, 'uppercase')
    .pattern(/[0-9]/, 'number')
    .required()
    .messages({
      'string.pattern.name': 'Password must contain at least one {#name} letter',
    }),
});

Migrating From Plain Text or Weak Hashes

If you have an existing database with insecurely stored passwords, migrate on login:

js
// features/auth/auth.service.js
export async function login({ email, password }) {
  const user = await User.findOne({ email }).select('+password +hashVersion');

  if (!user) throw new AppError('Invalid email or password', 401);

  // Legacy: plain text passwords (migrate immediately)
  if (user.hashVersion === 0) {
    if (user.password !== password) throw new AppError('Invalid email or password', 401);

    // Upgrade to bcrypt
    user.password = await hashPassword(password);
    user.hashVersion = 1;
    await user.save();
    return buildSession(user);
  }

  // Legacy: MD5 passwords
  if (user.hashVersion === 1) {
    const md5Hash = createMd5(password);
    if (user.password !== md5Hash) throw new AppError('Invalid email or password', 401);

    // Upgrade to bcrypt
    user.password = await hashPassword(password);
    user.hashVersion = 2;
    await user.save();
    return buildSession(user);
  }

  // Current: bcrypt
  const valid = await bcrypt.compare(password, user.password);
  if (!valid) throw new AppError('Invalid email or password', 401);

  return buildSession(user);
}

This upgrades each user's password the first time they log in after the migration — no bulk re-hash required, since you cannot recover plain-text passwords from hashes.


Upgrading bcrypt Cost Factor

As hardware improves, you may need to increase SALT_ROUNDS. Detect and re-hash on login:

js
export async function login({ email, password }) {
  const user = await User.findOne({ email }).select('+password');
  if (!user) throw new AppError('Invalid email or password', 401);

  const valid = await bcrypt.compare(password, user.password);
  if (!valid) throw new AppError('Invalid email or password', 401);

  // Re-hash if the stored hash uses a lower cost factor than current
  const currentRounds = bcrypt.getRounds(user.password);
  const targetRounds = parseInt(process.env.BCRYPT_ROUNDS);

  if (currentRounds < targetRounds) {
    user.password = await bcrypt.hash(password, targetRounds);
    await user.save(); // fire-and-forget in background would be faster
  }

  return buildSession(user);
}

Argon2 — The Modern Alternative

For new projects, Argon2id is the recommended standard from OWASP:

bash
npm install argon2
js
import argon2 from 'argon2';

// Hash
const hash = await argon2.hash(password, {
  type: argon2.argon2id,
  memoryCost: 65536,   // 64 MB
  timeCost: 3,         // 3 iterations
  parallelism: 4,
});

// Verify
const valid = await argon2.verify(hash, password);

Argon2id resists GPU cracking more effectively than bcrypt because it requires significant memory (not just CPU cycles). If you are starting fresh, prefer Argon2id. If you already use bcrypt, it is safe — no need to migrate.


Node.js Full‑Stack Course — Module 19 of 32

Your application now stores passwords correctly. Continue to Module 20 to build role-based access control and protect resources by user role.


    Summary

    Password security comes down to a small number of non-negotiable rules:

    • Never store plain text, MD5, or SHA-256 passwords — they are trivially cracked from any breach
    • Use bcrypt.hash(password, rounds) with at least 12 rounds in production — benchmark on your own hardware
    • Use bcrypt.compare(plain, hash) for verification — never compare hashes directly
    • Always use the async versionsbcrypt.hashSync blocks the Node.js event loop
    • Run bcrypt.compare even when the user is not found to prevent timing-based user enumeration
    • Validate password length before hashing — bcrypt silently truncates at 72 bytes
    • Detect outdated cost factors on login and re-hash transparently
    • For new projects, consider Argon2id — it is more resistant to GPU-based attacks

    Continue to Module 20: Role-Based Access Control →