Password Hashing with bcrypt in Node.js: Cost Factors, the 72-Byte Limit and Thread Pool Pitfalls
Key takeaways
bcrypt is a password hashing library designed to be slow and computationally expensive, making brute-force attacks impractical. It's the industry standard for secure password storage.
Introduction
bcrypt is a password-hashing function designed for secure password storage. Unlike fast hash functions (MD5, SHA256), bcrypt is intentionally slow, making brute-force attacks impractical.
Why bcrypt?
Insecure (never do this):
const password = 'mypassword123';
const hash = crypto.createHash('sha256').update(password).digest('hex');
Problems:
- Too fast (GPUs compute billions of such hashes per second)
- No salt (vulnerable to rainbow tables)
- Not designed for passwords
Secure (with bcrypt):
const bcrypt = require('bcrypt');
const hash = await bcrypt.hash('mypassword123', 10);
Benefits:
- Intentionally slow
- Automatic salting
- Adaptive (increase cost over time)
The reason “fast” is a problem is specific to passwords. For most hashing, such as file checksums or cache keys, speed is exactly what you want. But password hashes are built to survive a database leak. Once an attacker has the hash table offline, the only thing between them and the passwords is how many guesses per second they can test. A single modern GPU can compute billions of SHA-256 hashes per second, and human-chosen passwords come from a small space that dictionaries and mutation rules cover well. bcrypt with cost 10–12 brings that down to thousands of guesses per second per GPU. That does not make weak passwords safe, but it moves an attack on a typical password from minutes to years.
Salting solves a different problem. Without a salt, identical passwords produce identical hashes, so one precomputed table (or one cracked hash) breaks every user with the same password. bcrypt generates a random 128-bit salt for each hash and stores it inside the output string, which is why you never store a salt column yourself.
It is also worth knowing where bcrypt stands today. OWASP’s current recommendation lists Argon2id first, because it is memory-hard: it resists GPU and ASIC cracking better than bcrypt’s small fixed memory use. bcrypt is still an acceptable choice with a cost of at least 10, and it has mature, audited libraries in every language. If you are starting a new system and your platform has a good Argon2 binding, consider it. If you already have bcrypt, there is no emergency to migrate.
Installation
npm install bcrypt
bcrypt is a native C++ addon. That makes it fast, but it needs a compatible prebuilt binary or a working build toolchain. The typical failures are node-gyp errors on Windows, or an “invalid ELF header” crash when node_modules built on macOS is copied into a Linux Docker image. If you run into those, bcryptjs is a pure-JavaScript implementation with the same API and compatible hashes. It is roughly 30% slower, and its async functions run on the main thread in chunks, so they do not get the thread-pool benefit described below. Always run npm ci inside the container rather than copying node_modules in.
Basic Usage
Hash a Password
const bcrypt = require('bcrypt');
async function hashPassword(password) {
const saltRounds = 10;
const hash = await bcrypt.hash(password, saltRounds);
return hash;
}
// Usage
const hash = await hashPassword('mypassword123');
console.log(hash);
// $2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
The output string is self-describing. $2b$ is the algorithm version, 10 is the cost, the next 22 characters are the salt, and the remaining 31 are the hash itself. Because the cost and salt travel with the hash, bcrypt.compare() needs nothing but the password and this string, and hashes with different costs can live in the same column. That is what makes the gradual cost upgrade in section 11 possible. Store the value in a column of at least 60 characters. A VARCHAR(50) column silently truncating the hash is a classic cause of “every login fails after migration”.
Verify a Password
async function verifyPassword(password, hash) {
const match = await bcrypt.compare(password, hash);
return match;
}
// Usage
const isValid = await verifyPassword('mypassword123', hash);
console.log(isValid); // true
const isInvalid = await verifyPassword('wrongpassword', hash);
console.log(isInvalid); // false
Salt Rounds (Cost Factor)
The salt rounds parameter determines how slow the hash function is. Each increase by 1 doubles the time.
// Fast (less secure)
await bcrypt.hash(password, 8); // ~40ms
// Balanced (recommended)
await bcrypt.hash(password, 10); // ~150ms
// Secure (slower)
await bcrypt.hash(password, 12); // ~600ms
// Very secure (slow)
await bcrypt.hash(password, 14); // ~2400ms
Recommendation: Start with 10, increase as hardware improves.
The millisecond numbers above are illustrative. Measure on your own production hardware, because a burstable cloud instance can be several times slower than a laptop. A practical rule is to choose the highest cost at which one hash takes roughly 100–300 ms on your server. Remember that this cost applies to every login, so the cost factor also sets how many logins per second one CPU core can handle. At about 250 ms per hash, one core handles roughly four logins per second. That is usually fine for normal traffic, but it matters during a login spike, or when someone deliberately floods your login endpoint to use it as a CPU-exhaustion attack. That is another reason to rate limit it (section 11).
The 72-byte limit
bcrypt only uses the first 72 bytes of the input and silently ignores the rest. Two passwords that share the same first 72 bytes produce matching hashes. For typed passwords this rarely matters, but it matters for long passphrases, and bytes are not characters: many emoji and non-Latin characters take 3–4 bytes in UTF-8. It also matters if you ever hash userId + password or similar concatenations, where the prefix eats part of the budget. Enforce a maximum length (for example, 64 characters) so the limit is explicit instead of silent. Pre-hashing with SHA-256 to get around the limit is possible, but it has pitfalls of its own (raw digest bytes can contain a zero byte, which some bcrypt implementations treat as the end of the string), so only do it with a base64-encoded digest and a clear reason.
Manual Salt Generation
async function hashWithCustomSalt(password) {
// Generate salt
const salt = await bcrypt.genSalt(10);
console.log(salt); // $2b$10$N9qo8uLOickgx2ZMRZoMye
// Hash with salt
const hash = await bcrypt.hash(password, salt);
return hash;
}
Usually not needed — bcrypt.hash() does this automatically.
Synchronous API
// Hash (blocks event loop - not recommended)
const hash = bcrypt.hashSync('password123', 10);
// Verify (blocks event loop - not recommended)
const isValid = bcrypt.compareSync('password123', hash);
Warning: Use async versions in production to avoid blocking.
The difference is bigger than it looks. Node.js runs your JavaScript on one thread. hashSync at cost 12 takes about a quarter of a second, and during that time the process cannot serve any other request, including health checks. Under a few concurrent logins, the load balancer can see the instance as unresponsive and take it out of rotation. The async versions hand the work to libuv’s thread pool, so the event loop stays free. The sync functions are fine in CLI scripts, migrations, and seed files, where nothing else is waiting.
User Registration
const express = require('express');
const bcrypt = require('bcrypt');
const db = require('./database');
const app = express();
app.use(express.json());
app.post('/register', async (req, res) => {
try {
const { email, password } = req.body;
// Validate password strength
if (password.length < 8) {
return res.status(400).json({ error: 'Password too short' });
}
// Check if user exists
const existingUser = await db.findUserByEmail(email);
if (existingUser) {
return res.status(409).json({ error: 'Email already exists' });
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Save user
const user = await db.createUser({
email,
password: hashedPassword,
});
res.status(201).json({ id: user.id, email: user.email });
} catch (error) {
res.status(500).json({ error: 'Registration failed' });
}
});
User Login
app.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
// Find user
const user = await db.findUserByEmail(email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Verify password
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate token (JWT, session, etc.)
const token = generateAuthToken(user);
res.json({ token, user: { id: user.id, email: user.email } });
} catch (error) {
res.status(500).json({ error: 'Login failed' });
}
});
Returning the same “Invalid credentials” message for unknown emails and wrong passwords is correct, but this code still leaks which emails exist, through timing. When the email is unknown, the handler returns right after a database lookup, in a few milliseconds. When the email exists, it runs bcrypt.compare, which takes 100+ ms. An attacker can measure that difference and enumerate registered accounts without ever seeing a different error message. The fix is to always run one comparison: when the user is not found, compare the password against a fixed dummy hash (generated once at startup with the same cost) and then return 401.
This is the kind of issue that code review almost never catches, because each line looks right on its own. I only started checking for it after reading security audit reports where “account enumeration via response timing” came up again and again on login forms that returned perfectly uniform error messages. It is now one of the first things I look at in a login handler. (Note also that the registration endpoint in section 6 returns 409 for existing emails, which reveals the same information directly. Whether that is acceptable is a product decision, but make it deliberately.)
Password Reset
const crypto = require('crypto');
// Generate reset token
app.post('/forgot-password', async (req, res) => {
const { email } = req.body;
const user = await db.findUserByEmail(email);
if (!user) {
// Don't reveal if email exists
return res.json({ message: 'If email exists, reset link sent' });
}
// Generate random token
const resetToken = crypto.randomBytes(32).toString('hex');
const resetTokenHash = await bcrypt.hash(resetToken, 10);
const expiry = Date.now() + 3600000; // 1 hour
await db.saveResetToken(user.id, resetTokenHash, expiry);
// Send email with resetToken (not hash!)
await sendResetEmail(email, resetToken);
res.json({ message: 'If email exists, reset link sent' });
});
// Reset password
app.post('/reset-password', async (req, res) => {
const { token, newPassword } = req.body;
// Find user by token
const resetData = await db.findResetToken();
// Verify token
const isValidToken = await bcrypt.compare(token, resetData.hash);
if (!isValidToken || Date.now() > resetData.expiry) {
return res.status(400).json({ error: 'Invalid or expired token' });
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Update user
await db.updatePassword(resetData.userId, hashedPassword);
await db.deleteResetToken(resetData.userId);
res.json({ message: 'Password reset successful' });
});
The reset flow above has a real design flaw: db.findResetToken() takes no arguments, because there is no way to look up a bcrypt hash by its input. Each bcrypt hash has its own random salt, so you cannot hash the incoming token and search for the result. The only options would be to scan every pending token and compare each one (slow, and a CPU-exhaustion vector), or to require the email as well.
For reset tokens, bcrypt is the wrong tool. The token comes from crypto.randomBytes(32), which has 256 bits of entropy, so there is nothing to brute-force, and a slow hash adds no protection. Use a plain, fast, deterministic hash instead: store sha256(token) in an indexed column, then look up WHERE token_hash = sha256(incomingToken) AND expires_at > now(). Storing the hash rather than the token still means a leaked database does not hand out working reset links. Also make the token single-use (delete it in the same transaction that updates the password), and invalidate the user’s existing sessions after a reset.
The general rule: slow hashes like bcrypt are for low-entropy secrets chosen by humans. For high-entropy random secrets such as reset tokens, API keys, and session IDs, a fast hash like SHA-256 is correct.
Sequelize Integration
const { DataTypes } = require('sequelize');
const bcrypt = require('bcrypt');
const User = sequelize.define('User', {
email: {
type: DataTypes.STRING,
unique: true,
allowNull: false,
},
password: {
type: DataTypes.STRING,
allowNull: false,
},
}, {
hooks: {
// Hash password before save
beforeCreate: async (user) => {
if (user.password) {
user.password = await bcrypt.hash(user.password, 10);
}
},
beforeUpdate: async (user) => {
if (user.changed('password')) {
user.password = await bcrypt.hash(user.password, 10);
}
},
},
});
// Instance method to verify password
User.prototype.verifyPassword = async function(password) {
return await bcrypt.compare(password, this.password);
};
// Usage
const user = await User.create({
email: '[email protected]',
password: 'password123', // Automatically hashed
});
const isValid = await user.verifyPassword('password123');
Model hooks are convenient, but they only fire for instance operations. User.update({ password: 'x' }, { where: { id } }) and User.bulkCreate([...]) do not run beforeUpdate/beforeCreate unless you pass individualHooks: true. In that case the plaintext password is written straight to the column. Nothing errors, and every later login for that user fails, because bcrypt.compare against a non-bcrypt string simply returns false. If you use hooks, make sure every code path that changes passwords goes through an instance save(), or hash explicitly in one service function and do not use hooks at all.
Mongoose Integration
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true,
},
password: {
type: String,
required: true,
},
});
// Hash password before save
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 10);
next();
});
// Instance method to verify password
userSchema.methods.verifyPassword = async function(password) {
return await bcrypt.compare(password, this.password);
};
const User = mongoose.model('User', userSchema);
// Usage
const user = new User({
email: '[email protected]',
password: 'password123',
});
await user.save(); // Password automatically hashed
const isValid = await user.verifyPassword('password123');
Mongoose has the same trap. pre('save') does not run for findOneAndUpdate, updateOne, or updateMany, so an admin “change password” endpoint written with User.findByIdAndUpdate(id, { password }) stores plaintext. The isModified('password') guard is what prevents the opposite bug: without it, saving a user for any unrelated reason (for example, updating their name) would hash the already-hashed password, and that user could no longer log in. Also add select: false to the password field, so it is not included in query results by default and does not accidentally end up in an API response.
Security Best Practices
Never Log Passwords
// Bad
console.log('Password:', password);
logger.info('User registered', { password });
// Good
console.log('User registered');
logger.info('User registered', { email: user.email });
Rate Limit Login Attempts
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests
message: 'Too many login attempts, try again later',
});
app.post('/login', loginLimiter, async (req, res) => {
// Login logic
});
Use Constant-Time Comparison
bcrypt.compare() already uses constant-time comparison to prevent timing attacks. Never use === to compare hashes.
To be precise, hash === storedHash is not just a timing risk, it is simply the wrong operation for bcrypt. Hashing the same password twice produces different strings because the salt differs, so the comparison would always fail. The only correct check is bcrypt.compare(password, storedHash), which reads the salt and cost out of the stored hash and recomputes it. For comparing other secrets yourself (API key digests, HMAC signatures), use crypto.timingSafeEqual on equal-length buffers.
// Bad - vulnerable to timing attacks
if (hash === storedHash) { }
// Good - constant time
await bcrypt.compare(password, hash);
Enforce Strong Passwords
function isStrongPassword(password) {
return (
password.length >= 8 &&
/[A-Z]/.test(password) &&
/[a-z]/.test(password) &&
/[0-9]/.test(password) &&
/[^A-Za-z0-9]/.test(password)
);
}
Composition rules like this are common, but current guidance has moved away from them. NIST SP 800-63B recommends a minimum length (at least 8, and longer is better), allowing long passphrases and all characters, and checking new passwords against lists of known breached passwords, instead of requiring character classes. The reason is that users satisfy these rules in predictable ways (Password1!), which attackers’ mutation rules already try first. If you keep a rule check, pair it with a breached-password check (for example, the k-anonymity range API from Have I Been Pwned) and a maximum length that matches the 72-byte limit.
Increase Cost Factor Over Time
async function rehashIfNeeded(user, password) {
const currentCost = 10;
const hash = user.password;
// Extract cost from hash
const cost = bcrypt.getRounds(hash);
if (cost < currentCost) {
const newHash = await bcrypt.hash(password, currentCost);
await db.updatePassword(user.id, newHash);
}
}
Call this right after a successful login, because that is the only moment you have the plaintext password. Users who never log in keep their old cost indefinitely, which is the accepted trade-off of this approach. The same pattern lets you migrate algorithms: check the hash prefix, and if it is an old format (for example, a legacy SHA-1 hash), verify with the old method and re-store with bcrypt. For accounts that stay inactive, some teams wrap the old hash instead (bcrypt(sha1_hash)) so the weak hashes are protected immediately, without waiting for a login.
Performance Optimization
Don’t Hash in Request Handler
// Bad - blocks all requests
app.post('/register', (req, res) => {
const hash = bcrypt.hashSync(password, 14); // Blocks!
});
// Good - non-blocking
app.post('/register', async (req, res) => {
const hash = await bcrypt.hash(password, 14);
});
Use Worker Threads for Heavy Load
const { Worker } = require('worker_threads');
function hashPasswordWorker(password, saltRounds) {
return new Promise((resolve, reject) => {
const worker = new Worker('./hash-worker.js', {
workerData: { password, saltRounds },
});
worker.on('message', resolve);
worker.on('error', reject);
});
}
For the native bcrypt package, this worker is usually unnecessary. Its async functions already run on libuv’s thread pool. The real limit is that pool’s size, which defaults to 4 threads and is shared with fs, dns.lookup, zlib, and crypto.pbkdf2. With 20 logins arriving at once at 250 ms each, the fifth login waits for a free thread, and so does every unrelated file read in the process. Latency climbs across the whole service, and the profile shows nothing obviously wrong, because the event loop itself is idle.
This is the bcrypt performance issue I would expect to see in production, rather than a blocked event loop. The symptom is that unrelated endpoints slow down during login spikes. The fixes are to raise UV_THREADPOOL_SIZE (set it as an environment variable before Node starts; it is read once), to size it to the number of CPU cores, and to rate limit authentication endpoints so a burst cannot saturate the pool. Spawning a new Worker per hash, as above, adds several milliseconds of startup cost per call. If you really need dedicated workers, use a pool library such as Piscina that reuses them.
Testing
const bcrypt = require('bcrypt');
describe('Password Hashing', () => {
it('should hash password', async () => {
const password = 'password123';
const hash = await bcrypt.hash(password, 10);
expect(hash).not.toBe(password);
expect(hash).toMatch(/^\$2[ab]\$/);
});
it('should verify correct password', async () => {
const password = 'password123';
const hash = await bcrypt.hash(password, 10);
const isValid = await bcrypt.compare(password, hash);
expect(isValid).toBe(true);
});
it('should reject incorrect password', async () => {
const password = 'password123';
const hash = await bcrypt.hash(password, 10);
const isValid = await bcrypt.compare('wrongpassword', hash);
expect(isValid).toBe(false);
});
it('should generate unique hashes', async () => {
const password = 'password123';
const hash1 = await bcrypt.hash(password, 10);
const hash2 = await bcrypt.hash(password, 10);
expect(hash1).not.toBe(hash2); // Different salts
});
});
bcrypt or Argon2id
For a new system with no constraints, the OWASP Password Storage Cheat Sheet recommends Argon2id first, because it is memory-hard: an attacker cannot run many guesses in parallel on a GPU without paying for memory as well as compute. bcrypt remains an acceptable choice when Argon2id is not available on your platform, or for an existing system where changing algorithms is not justified. Its practical weaknesses are the ones covered above: the 72-byte input limit and a cost factor that has to be raised over time.
Whichever you use, the migration path is the same. Store hashes in their self-describing format, check the algorithm and cost on each successful login, and rehash the plain password while you still have it. Old hashes upgrade gradually without forcing password resets.
Resources:
Related Articles
- Node.js Authentication and Security: JWT, bcrypt, and Sessions
- API Rate Limiting: Fixed Window, Sliding Log and Token Bucket, and Making Them Atomic in Redis
- Securing Express with Helmet
- JWT Authentication
- Building an Express API