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:
$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
npm install bcryptbcrypt has a native C++ addon for performance. If you need a pure JavaScript fallback (e.g., serverless environments with limited native support):
npm install bcryptjs # pure JS, ~30% slower, no native compilationThe API is identical — just change the import.
Choosing Salt Rounds
Run this benchmark on your production hardware to pick the right value:
// 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:
Rounds | Time (ms)
-------|----------
8 | 20
9 | 40
10 | 80
11 | 160
12 | 320
13 | 640
14 | 1280A 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
// 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);
}# .env
BCRYPT_ROUNDS=12Using hashPassword in a Service
// 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
// 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
// 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
// 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
// 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:
// 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:
// 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:
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:
npm install argon2import 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 versions —
bcrypt.hashSyncblocks the Node.js event loop - Run
bcrypt.compareeven 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 →
