bcrypt
bcrypt is the classic Node.js library for hashing passwords. It is a native addon wrapping the OpenBSD bcrypt implementation: you pick a cost factor (rounds), call hash() to get a salted 60-character hash, and later call compare() to check a login attempt. The cost factor makes hashing deliberately slow (2^rounds iterations), which is the whole point: it makes brute-forcing stolen hashes expensive. The async API runs hashing on a thread pool so it does not block the event loop. It does one job, has done it for over a decade, and the API is essentially frozen.
Battle-tested and fine for existing systems with bcrypt hashes, but it is no longer the default answer for new code: argon2 is the stronger algorithm and bcryptjs removes the native-build risk. Pick bcrypt for compatibility, not by reflex.
Use it if
- You store user passwords and need the industry-default hash with salt handling built in
- You inherit a database of existing bcrypt hashes ($2a$ or $2b$ prefixes) and need compatibility
- You run on standard Node 18+ server platforms where the prebuilt native binaries just work
- You want hashing off the event loop: the async API uses a worker thread pool
- You are starting a new project and can choose freely: OWASP's current first recommendation for password hashing is Argon2id, and the argon2 npm package is just as easy to use
- You deploy to edge runtimes or anywhere native addons cannot load (Cloudflare Workers, some bundled Electron setups); bcryptjs is pure JS and produces compatible hashes
- Your users may have long or emoji-heavy passwords and you have not planned for it: bcrypt silently uses only the first 72 bytes of the input, the rest is ignored
- Your platform has no prebuilt binary: you fall into a node-gyp build that needs Python and a C++ toolchain, historically the top source of issues for this package
- You need fast-moving maintenance: releases are infrequent (v6.0.0 shipped May 2025) and the project explicitly supports only stable Node releases
Setup reality
On Windows x64/arm64, mainstream Linux (glibc and musl) x64/arm64, and macOS, npm install bcrypt pulls a prebuilt binary via node-gyp-build and you are done. Off that happy path (unusual arch, brand-new Node release before prebuilds land, restricted networks), it compiles from source, which drags in node-gyp, Python, and a C++ toolchain, and on Windows the Visual Studio C++ workload. v6 requires Node 18+. Also plan for the 72-byte input truncation and the fact that TypeScript types are a separate @types/bcrypt install.
Patterns
Hash a password before storing ithash-password
import bcrypt from 'bcrypt';
const saltRounds = 10;
const hash = await bcrypt.hash(plainPassword, saltRounds);
// store `hash` in your DB; the salt is embedded in itNever store the plain password anywhere; the 60-char hash string contains algorithm, cost, and salt, so no separate salt column is needed.
Check a login attempt against a stored hashverify-password
const ok = await bcrypt.compare(plainPassword, user.passwordHash);
if (!ok) {
return res.status(401).json({ error: 'Invalid credentials' });
}compare() extracts cost and salt from the stored hash automatically; never compare hash strings with === yourself.
Pick a cost factor deliberatelychoose-cost-factor
// rounds means 2^rounds iterations. README ballpark on a 2GHz core:
// 10 -> ~10 hashes/sec, 12 -> 2-3 hashes/sec, 13 -> ~1 sec/hash
const saltRounds = 12;
const hash = await bcrypt.hash(password, saltRounds);Benchmark on your production hardware and pick the highest rounds your login latency budget tolerates; 10 is the default, many teams now run 12.
Use async, not the Sync variants, on serversavoid-sync-blocking
// BAD on a server: blocks the event loop for the full hash time
const hash = bcrypt.hashSync(password, 12);
// GOOD: runs on the worker thread pool
const hash2 = await bcrypt.hash(password, 12);At cost 12 hashSync freezes your process for hundreds of milliseconds per call; the README itself recommends the async API on servers.
Guard against the 72-byte truncationhandle-72-byte-limit
const BYTE_LIMIT = 72;
if (Buffer.byteLength(password, 'utf8') > BYTE_LIMIT) {
// bytes beyond 72 are silently ignored by bcrypt
return res.status(400).json({ error: 'Password too long (72 bytes max)' });
}
const hash = await bcrypt.hash(password, 12);The limit is bytes, not characters: emoji and non-Latin scripts hit it far sooner than 72 characters.
Transparently upgrade old hashes at loginupgrade-rounds-on-login
const TARGET_ROUNDS = 12;
const ok = await bcrypt.compare(password, user.passwordHash);
if (ok && bcrypt.getRounds(user.passwordHash) < TARGET_ROUNDS) {
const newHash = await bcrypt.hash(password, TARGET_ROUNDS);
await users.updateOne({ _id: user._id }, { $set: { passwordHash: newHash } });
}Login is the only moment you hold the plain password, so it is the only place you can re-hash at a higher cost.
Registration route with hashingexpress-register-route
app.post('/register', async (req, res) => {
const { email, password } = req.body;
const passwordHash = await bcrypt.hash(password, 12);
await users.insertOne({ email, passwordHash });
res.status(201).json({ ok: true });
});Do the hashing inside the request handler with await; wrapping hashSync in a route is the classic accidental event-loop stall.
Generate a salt as a separate stepgenerate-salt-manually
const salt = await bcrypt.genSalt(12);
const hash = await bcrypt.hash(password, salt);Functionally identical to passing rounds straight to hash(); only useful when you want to log or reuse the salt string.
Inspect the cost factor of a stored hashread-hash-rounds
const rounds = bcrypt.getRounds('$2b$10$nOUIs5kJ7naTuTFkBy1veuK0kSxUFXfuaOKdOKf9xYT0KKIGSJwFa');
console.log(rounds); // 10Handy for auditing a user table to see how many hashes still sit at an outdated cost factor.
Avoid user-enumeration via response timingdummy-compare-timing
const DUMMY_HASH = await bcrypt.hash('dummy-password', 12); // do once at boot
const user = await users.findOne({ email });
const hashToCheck = user ? user.passwordHash : DUMMY_HASH;
const ok = await bcrypt.compare(password, hashToCheck);
if (!user || !ok) return res.status(401).json({ error: 'Invalid credentials' });Without a dummy compare, unknown emails return instantly while known ones take ~100ms, which leaks which emails have accounts.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| argon2 | npm | New projects: Argon2id is the current OWASP first choice and this binding is well maintained |
| bcryptjs | npm | You need bcrypt-compatible hashes without any native compilation (edge, Electron, odd platforms) |
| @node-rs/bcrypt | npm | You want bcrypt semantics with Rust-built prebuilds and no node-gyp in sight |