bcrypt Password Hashing
Storing a user’s password in plain text — or even as a fast cryptographic hash like SHA-256 — is one of the most damaging mistakes an Express app can make. Passwords must be hashed with a slow, salted algorithm so that a stolen database is useless to an attacker. The bcrypt package implements exactly that, deriving a per-password salt and deliberately burning CPU time to make brute-force attacks expensive. This page covers hashing on signup, comparing on login, choosing a cost factor, the async-versus-sync trade-off, and the pure-JS bcryptjs alternative.
Why bcrypt instead of a plain hash
General-purpose hashes (MD5, SHA-1, SHA-256) were designed to be fast, which is exactly the wrong property for passwords: modern GPUs can compute billions of SHA-256 hashes per second, so a leaked table of fast hashes is cracked in hours. bcrypt is built on the Blowfish cipher and is intentionally slow and tunable. It also salts every password automatically, so two users with the same password get different hashes and precomputed “rainbow tables” are worthless.
A bcrypt hash is a single self-describing string that embeds the algorithm version, the cost factor, and the salt — so you store just one column and never manage salts yourself:
$2b$12$Nf8r0G7sJqK1l2m3n4o5p.uQwErTyUiOpAsDfGhJkLzXcVbNmQwEa
└┬┘└┬┘ └──────────┬─────────┘└────────────┬───────────────┘
│ │ │ │
│ cost (12) 22-char salt 31-char hash
algorithm ($2b)
npm install bcrypt
bcrypt is a native addon and compiles against your Node.js version. If installation fails on a slim Docker image or your CI lacks build tools, use the drop-in pure-JavaScript
bcryptjspackage instead (covered below).
Hashing a password on signup
Use bcrypt.hash(plaintext, saltRounds) to produce the hash to store. The saltRounds argument is the cost factor: bcrypt runs 2^rounds iterations, so each extra round doubles the work. Generating the salt and hashing happen in one call — you do not call genSalt separately unless you want the salt for another reason.
const express = require("express");
const bcrypt = require("bcrypt");
const app = express();
app.use(express.json());
const SALT_ROUNDS = 12;
app.post("/signup", async (req, res) => {
const { email, password } = req.body;
if (!password || password.length < 8) {
return res.status(400).json({ error: "Password too short" });
}
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
const user = await db.users.create({ email, passwordHash });
res.status(201).json({ id: user.id, email: user.email });
});
app.listen(3000, () => console.log("Listening on http://localhost:3000"));
Notice the plain-text password never leaves this handler and is never logged. Only passwordHash is persisted.
Output:
< HTTP/1.1 201 Created
< Content-Type: application/json
{"id":42,"email":"[email protected]"}
Comparing a password on login
On login you cannot “decrypt” the stored hash — bcrypt is one-way. Instead you re-hash the submitted password against the stored hash and let bcrypt check whether they match. bcrypt.compare(plaintext, hash) reads the cost and salt out of the stored hash, recomputes, and returns a boolean.
app.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
// Always run compare, even for a missing user, to avoid timing leaks.
const stored = user?.passwordHash ?? "$2b$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinva";
const ok = await bcrypt.compare(password, stored);
if (!user || !ok) {
return res.status(401).json({ error: "Invalid credentials" });
}
// ... issue a session or JWT here
res.json({ message: "Logged in", userId: user.id });
});
Return the same “Invalid credentials” error whether the email is unknown or the password is wrong. Telling an attacker “no such user” versus “wrong password” leaks which accounts exist (user enumeration).
Choosing the salt rounds (cost factor)
The cost factor is a security-versus-latency dial. Higher rounds resist brute force better but make every login and signup slower. The right value keeps a single hash in the tens-to-hundreds of milliseconds on your production hardware — slow enough to deter attackers, fast enough not to stall your event loop or annoy users. Re-benchmark every couple of years as hardware improves.
| Salt rounds | Iterations (2^n) | Approx. time | When to use |
|---|---|---|---|
| 8 | 256 | ~10 ms | Low-power devices, tests |
| 10 | 1,024 | ~70 ms | Library default; light apps |
| 12 | 4,096 | ~250 ms | Recommended baseline today |
| 14 | 16,384 | ~1 s | High-value accounts |
// Quick benchmark for your hardware
console.time("hash");
await bcrypt.hash("benchmark-password", 12);
console.timeEnd("hash");
Output:
hash: 248.317ms
Async vs. sync
bcrypt ships both asynchronous (hash, compare) and synchronous (hashSync, compareSync) APIs. Because hashing is CPU-bound and deliberately slow, the synchronous variants block the Node.js event loop for the full duration — at 12 rounds that is a quarter-second during which your server cannot handle any other request. The async functions offload the work to libuv’s thread pool, keeping the server responsive.
Async (hash / compare) | Sync (hashSync / compareSync) | |
|---|---|---|
| Event loop | Non-blocking (thread pool) | Blocks for the full hash |
| API | Promise (or callback) | Returns a value directly |
| Use in Express | Always — inside async handlers | Only in scripts/CLIs/seeds |
// Good: non-blocking, returns a promise
const hash = await bcrypt.hash(password, 12);
// Avoid in request handlers: freezes every other request
const hash = bcrypt.hashSync(password, 12);
The bcryptjs alternative
bcryptjs is a pure-JavaScript reimplementation with the same API and a compatible hash format, so hashes are interchangeable between the two libraries. It needs no native compiler, which makes it ideal for serverless platforms, Alpine/musl containers, or anywhere node-gyp is a pain. The trade-off is speed: because it cannot use the thread pool the same way, its async calls still execute on the main thread and it runs noticeably slower than native bcrypt.
// Drop-in replacement — only the import changes
const bcrypt = require("bcryptjs");
const hash = await bcrypt.hash(password, 12);
const ok = await bcrypt.compare(password, hash);
Choose native bcrypt for raw performance on a controlled server, and bcryptjs when portability and a zero-build install matter more.
Best Practices
- Never store plain text or fast hashes (MD5/SHA) — always use bcrypt (or Argon2/scrypt) with its built-in per-password salt.
- Use
bcrypt.hash/bcrypt.compare, not theSyncvariants, inside Express request handlers so you never block the event loop. - Start at 12 salt rounds and re-benchmark periodically; raise the cost as hardware gets faster.
- Return an identical generic error for unknown users and wrong passwords to prevent account enumeration.
- Validate and length-cap the password before hashing — bcrypt silently truncates input beyond 72 bytes.
- Keep the hash in its own column and never log or return it in API responses.
- When you increase the cost factor, transparently re-hash a user’s password on their next successful login.