Password Hashing with bcrypt

Storing a user’s password in your database is never acceptable, even if the database itself is well protected — a single leak, backup, or misconfigured permission turns every plaintext password into an instant compromise. Instead, you store a hash: the output of a one-way function that turns the password into a fixed-length string you cannot reverse back into the original. bcrypt is the most widely used password-hashing library in the Node.js ecosystem, and it was designed specifically to be slow and salted, which is exactly what makes it good at this job. This lesson covers how bcrypt works, how to use it correctly with async/await, and the mistakes that quietly undermine it.

Overview: Why You Never Store Plain Passwords

Hashing is not encryption. Encryption is reversible — you decrypt ciphertext back into plaintext with a key. Hashing is one-way: you feed data in, you get a fixed-length digest out, and there is no key that turns the digest back into the original data. For passwords, this is exactly the property you want: your server never needs to recover the original password, it only ever needs to check whether a newly submitted password produces the same result as the one stored at signup.

The problem is that general-purpose hash functions like SHA-256 or MD5 are fast — they were designed for checksums and digital signatures, where speed is a feature. Speed is a liability for passwords. A modern GPU can compute billions of SHA-256 hashes per second, so an attacker who steals a table of unsalted SHA-256 password hashes can brute-force short or common passwords in minutes using precomputed rainbow tables or straightforward guessing. Two users with the same password also produce the same hash, which leaks that fact to anyone with database access.

bcrypt solves both problems. First, it automatically generates a random salt — extra random bytes mixed into the input — so the same password hashed twice produces two completely different outputs, and precomputed rainbow tables become useless. Second, it is deliberately slow, and that slowness is tunable via a cost factor (also called the number of “rounds”). Internally, bcrypt runs a modified version of the Blowfish cipher’s key setup 2^rounds times. Each increment of the cost factor doubles the work required, both for you and for an attacker trying to brute-force it. A cost factor of 12 (the common modern default) takes on the order of a couple hundred milliseconds on typical server hardware — imperceptible for a single login, but ruinously slow for an attacker trying billions of guesses.

Syntax

Install the package first:

npm install bcrypt

The library exposes both promise-based and synchronous functions:

Function Returns Description
bcrypt.hash(data, saltOrRounds) Promise<string> Hashes data. If saltOrRounds is a number, bcrypt generates a fresh random salt with that many rounds.
bcrypt.hashSync(data, saltOrRounds) string Blocking version of hash. Runs on the main thread — avoid in request handlers.
bcrypt.compare(data, encrypted) Promise<boolean> Re-hashes data using the salt embedded in encrypted and compares the results.
bcrypt.compareSync(data, encrypted) boolean Blocking version of compare.
bcrypt.genSalt(rounds) Promise<string> Generates a salt string separately, if you want to control salt generation explicitly.
bcrypt.getRounds(hash) number Reads the cost factor back out of an existing hash — useful for detecting hashes created with an outdated (too-low) cost.

Examples

Example 1: Hashing and Verifying a Password

import bcrypt from "bcrypt";

const password = "correct horse battery staple";
const saltRounds = 12;

const hash = await bcrypt.hash(password, saltRounds);
console.log("Hash:", hash);

const isMatch = await bcrypt.compare(password, hash);
console.log("Match:", isMatch);

const isWrong = await bcrypt.compare("wrong-password", hash);
console.log("Wrong password match:", isWrong);

Output:

Hash: $2b$12$uY8mE1sVnQe0kFz9J2iLreS5v7c1yQeYb0hR3nQe7wXk9zF1mYcCe
Match: true
Wrong password match: false

The hash string itself encodes everything needed to verify it later: the algorithm version ($2b$), the cost factor (12), and the salt, all packed in before the actual hash digest. That’s why compare only needs the plaintext and the stored hash — it extracts the salt and rounds from the hash itself. Run this snippet twice with the same password and you’ll get two different hash strings, because the salt is random each time; that’s expected and correct.

Example 2: Registration and Login with Express

npm install express bcrypt
import express from "express";
import bcrypt from "bcrypt";

const app = express();
app.use(express.json());

const SALT_ROUNDS = 12;
const users = new Map(); // demo only - replace with a real database

app.post("/register", async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password) {
    return res.status(400).json({ error: "email and password are required" });
  }
  if (users.has(email)) {
    return res.status(409).json({ error: "user already exists" });
  }

  try {
    const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
    users.set(email, { email, passwordHash });
    res.status(201).json({ email });
  } catch (err) {
    res.status(500).json({ error: "could not register user" });
  }
});

app.post("/login", async (req, res) => {
  const { email, password } = req.body;
  const user = users.get(email);

  if (!user) {
    return res.status(401).json({ error: "invalid credentials" });
  }

  try {
    const isValid = await bcrypt.compare(password, user.passwordHash);
    if (!isValid) {
      return res.status(401).json({ error: "invalid credentials" });
    }
    res.json({ message: "logged in", email });
  } catch (err) {
    res.status(500).json({ error: "login failed" });
  }
});

app.listen(3000, () => console.log("listening on port 3000"));

This is close to a real registration/login flow. Notice two things: the /login route always returns the same generic “invalid credentials” message whether the email doesn’t exist or the password is wrong — never reveal which one it was, or you hand attackers a free way to enumerate valid accounts. And both routes wrap the bcrypt call in try/catch, because a malformed stored hash or an unexpected input can make compare reject.

Example 3: Tuning the Cost Factor

import bcrypt from "bcrypt";

async function timeHash(rounds) {
  const start = Date.now();
  await bcrypt.hash("sample-password", rounds);
  const ms = Date.now() - start;
  console.log(`rounds=${rounds} took ${ms}ms`);
}

for (const rounds of [10, 12, 14]) {
  await timeHash(rounds);
}

Output (timings vary by hardware — this shows the trend, not exact numbers):

rounds=10 took 68ms
rounds=12 took 251ms
rounds=14 took 998ms

Each extra round roughly doubles the time, because the cost factor is an exponent (2^rounds). This is the whole point: pick the highest cost factor your server can afford per login without hurting user experience — 12 is a reasonable modern default — and re-hash old accounts with a higher cost as hardware gets faster. bcrypt.getRounds(hash) lets you check an existing hash’s cost and upgrade it after a successful login if it’s below your current target.

How bcrypt Works Under the Hood

  • 1. Salt generation. When you call bcrypt.hash(password, rounds), bcrypt first generates a cryptographically random salt (bcrypt calls internally into Node’s secure random source).
  • 2. Key setup. It runs a modified Blowfish key-schedule algorithm called eksblowfish, seeded with the password and salt, and repeats the expensive key-setup step 2^rounds times. This is the actual “work” that makes bcrypt slow.
  • 3. Encoding. The result is packed into a single string: version identifier, cost factor, salt, and hash digest, all base64-like encoded together — e.g. $2b$12$<22-char-salt><31-char-hash>. This is the only thing you store; you never store the salt separately.
  • 4. Offloading the work. The async functions (hash, compare) run the CPU-heavy computation in libuv’s thread pool, not on the main thread, so the event loop stays free to handle other requests while a hash is computing. The default thread pool size is 4 (UV_THREADPOOL_SIZE); under very high login concurrency, many simultaneous bcrypt calls can queue up waiting for a free thread pool slot — worth knowing if you ever need to tune that variable.
  • 5. Verification. bcrypt.compare(password, hash) reads the salt and cost factor back out of the stored hash string, re-runs the exact same key-setup process on the submitted password, and compares the two resulting digests using a constant-time comparison so the check doesn’t leak timing information about how many characters matched.

Common Mistakes

Mistake 1: Hashing with a fast, unsalted algorithm

import crypto from "node:crypto";

function hashPassword(password) {
  return crypto.createHash("sha256").update(password).digest("hex");
}

// same input always produces the same output - no salt, and it's fast
// enough to brute-force billions of guesses per second on a GPU
const hash = hashPassword("hunter2");

SHA-256 is a general-purpose hash function, not a password hash. It has no built-in salt and no adjustable cost, so it is fast — exactly the wrong property for this job. Use bcrypt.hash(password, 12) instead, as shown in Example 1.

Mistake 2: Using the sync API inside a request handler

app.post("/register", (req, res) => {
  const hash = bcrypt.hashSync(req.body.password, 12);
  users.set(req.body.email, hash);
  res.sendStatus(201);
});

hashSync runs the entire cost-factor-12 computation (roughly 200-300ms) synchronously on the main thread, blocking the event loop — every other request, including unrelated ones, has to wait. Under any real traffic this tanks throughput and can cause request timeouts. Use the promise-based hash instead:

app.post("/register", async (req, res) => {
  const hash = await bcrypt.hash(req.body.password, 12);
  users.set(req.body.email, hash);
  res.sendStatus(201);
});

Mistake 3: Comparing hash strings with ===

const submittedHash = crypto.createHash("sha256").update(password).digest("hex");

if (submittedHash === user.passwordHash) {
  // treated as logged in - vulnerable to timing attacks, and only
  // works at all because the "hash" above is unsalted SHA-256
}

Never hash the submitted password yourself and compare strings — you’d need to know the exact salt and algorithm bcrypt used, and a plain === comparison on secret data can leak timing information. Always let bcrypt do the comparison:

const isValid = await bcrypt.compare(password, user.passwordHash);

if (isValid) {
  // logged in
}

Other gotchas worth knowing

bcrypt silently truncates the input password at 72 bytes — anything beyond that is ignored when hashing. This is rarely an issue for typical passwords but matters if you accept very long passphrases; if you need to support longer inputs safely, pre-hash with SHA-256 before passing to bcrypt, or use a modern alternative like argon2. Also avoid setting the cost factor too low (below ~10) purely to make development faster — use a lower value only in a test environment, never in the code path that ships to production.

Best Practices

  • Use a cost factor of at least 12 in production; increase it over time as hardware improves, and use bcrypt.getRounds() to detect and re-hash old, under-cost hashes on next successful login.
  • Always use the async API (hash/compare) in server code — reserve the sync API for one-off scripts or CLI tools where blocking doesn’t matter.
  • Never log, return, or store the plaintext password anywhere, even temporarily — including in error messages or crash reports.
  • Return identical, generic error messages for “user not found” and “wrong password” so login endpoints don’t leak which accounts exist.
  • Wrap every bcrypt.hash/compare call in try/catch — malformed hashes or unexpected input types cause rejections you must handle.
  • Enforce a reasonable maximum password length before hashing, both to avoid the 72-byte truncation surprise and to prevent someone submitting a multi-megabyte string as a denial-of-service vector.
  • Rate-limit login attempts at the route or reverse-proxy level — bcrypt’s cost slows brute force per-guess, but it doesn’t stop an attacker from simply making many requests.

Practice Exercises

  • Write a small Node script that hashes a password with bcrypt, prints the hash, then reads the cost factor back out of that hash using bcrypt.getRounds() and prints it.
  • Extend the Example 2 Express app with a PATCH /users/:email/password route that verifies the current password with bcrypt.compare before allowing it to be changed, and hashes the new password before saving it.
  • Modify Example 3 to also record how long bcrypt.compare takes for a correct vs. an incorrect password at the same cost factor, and confirm the timings are close enough that no timing attack is practical.

Summary

  • Never store plaintext passwords — store a one-way hash, and use bcrypt (or another purpose-built password hash) rather than a fast general-purpose hash like MD5 or SHA-256.
  • bcrypt automatically salts every hash and encodes the algorithm version, cost factor, and salt into the stored hash string itself.
  • The cost factor is exponential — each +1 round roughly doubles the computation time; 12 is a solid modern default.
  • Always use the async bcrypt.hash/bcrypt.compare in server code; the sync versions block the event loop.
  • Let bcrypt.compare do the comparison — never hash manually and compare strings with ===.
  • Passwords longer than 72 bytes are silently truncated by bcrypt — know this limit if you accept long passphrases.