Skip to content
Express.js ex auth 5 min read

Password Hashing with bcrypt

Never store a user’s password as plain text. If your database is leaked — and breaches are routine — plaintext passwords hand attackers every account instantly, plus every other account where the user reused that password. The defense is to store a one-way hash that can verify a password without ever revealing it. bcrypt is the long-standing, battle-tested choice for this in Node and Express: it is deliberately slow, salts every hash automatically, and exposes a tiny two-function API for hashing on registration and comparing on login.

Why a plain hash isn’t enough

A naive instinct is to reach for a fast hash like SHA-256. That fails for two reasons. First, fast hashes are too fast — a modern GPU computes billions of SHA-256 hashes per second, so attackers brute-force common passwords trivially. Second, identical passwords produce identical hashes, which lets attackers use precomputed rainbow tables and instantly spot users who share a password.

bcrypt solves both. It is an intentionally slow algorithm whose cost is tunable, and it generates a random salt for every password, mixing it into the hash so two users with the same password get completely different hashes. The salt is stored inside the resulting hash string, so you don’t manage it separately.

$2b$12$Ro0CUfOqk6cXEKf3dyaM7OhSCvnwM9s4dKkS7eyqq2vXh4i3FAY8K
 │  │  └ 22-char salt ──────────┘└ 31-char hash ─────────────┘
 │  └ cost factor (2^12 rounds)
 └ algorithm version (2b)

Installing bcrypt

The native bcrypt package offers the best performance; bcryptjs is a pure-JS drop-in with the same API if you can’t compile native modules.

npm install bcrypt
const bcrypt = require("bcrypt");

Hashing on registration

When a user signs up, hash the incoming plaintext and persist only the hash. bcrypt.hash(plaintext, saltRounds) returns a promise that resolves to the complete hash string (salt included). Store that string in a column like password_hash — never the original.

const express = require("express");
const bcrypt = require("bcrypt");

const router = express.Router();
const SALT_ROUNDS = 12;

router.post("/register", async (req, res) => {
  const { email, password } = req.body;

  if (!email || !password || password.length < 8) {
    return res.status(400).json({ error: "Email and 8+ char password required" });
  }

  const existing = await db.users.findByEmail(email);
  if (existing) {
    return res.status(409).json({ error: "Email already registered" });
  }

  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 });
});

module.exports = router;

Output:

$ curl -X POST http://localhost:3000/auth/register \
    -H "Content-Type: application/json" \
    -d '{"email":"[email protected]","password":"correct horse battery"}'
{"id":"u_123","email":"[email protected]"}

The plaintext password is used once, hashed, and immediately discarded — it never touches your database or logs.

Verifying on login

On login, look up the user by email, then compare the submitted plaintext against the stored hash with bcrypt.compare(plaintext, hash). It re-derives the salt from the stored hash, hashes the candidate the same way, and returns a boolean — all in constant time relative to the hash, which avoids timing leaks.

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

  const user = await db.users.findByEmail(email);
  // Always run compare even if the user is missing, to keep timing uniform.
  const hash = user?.passwordHash ?? "$2b$12$invalidinvalidinvalidinvalidinvalidinv";
  const ok = await bcrypt.compare(password, hash);

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

  // Issue a session or JWT here.
  res.json({ id: user.id, email: user.email });
});

Output:

$ curl -X POST http://localhost:3000/auth/login \
    -H "Content-Type: application/json" \
    -d '{"email":"[email protected]","password":"wrong"}'
{"error":"Invalid credentials"}

Tip: Return the same generic 401 Invalid credentials whether the email is unknown or the password is wrong. Distinguishing the two leaks which emails are registered (account enumeration). Running bcrypt.compare against a dummy hash for missing users, as above, keeps response timing uniform too.

Choosing salt rounds

The “salt rounds” (cost factor) is a base-2 exponent: 12 means 2¹² = 4096 key-expansion iterations. Each extra round doubles the work — and the attacker’s cost. Tune it so a single hash takes roughly 100–300 ms on your production hardware: slow enough to frustrate brute force, fast enough not to stall your login endpoint.

Salt roundsApprox. time/hashNotes
8~10 msToo fast for 2026 hardware
10~60 msCommon older default
12~250 msSensible modern default
14~1 sHigh security, noticeable latency

Because the cost is embedded in the hash, you can raise rounds later: when a user logs in successfully and their stored hash uses an old cost, re-hash the password with the new cost and save it transparently.

Migrating to argon2

bcrypt is still strong, but Argon2id is the winner of the Password Hashing Competition and resists GPU/ASIC attacks better thanks to its memory-hardness. Its API mirrors bcrypt closely, so migration is gradual — verify against the existing bcrypt hash on login, then re-hash with Argon2 and store the new value.

npm install argon2
const argon2 = require("argon2");
const bcrypt = require("bcrypt");

async function verifyAndUpgrade(user, password) {
  const isArgon = user.passwordHash.startsWith("$argon2");

  const ok = isArgon
    ? await argon2.verify(user.passwordHash, password)
    : await bcrypt.compare(password, user.passwordHash);

  if (ok && !isArgon) {
    // Seamlessly upgrade legacy bcrypt hashes on successful login.
    const newHash = await argon2.hash(password, { type: argon2.argon2id });
    await db.users.update(user.id, { passwordHash: newHash });
  }
  return ok;
}

This lets you adopt Argon2 for everyone over time without forcing a mass password reset.

Best Practices

  • Store only the bcrypt/Argon2 hash, never plaintext — and keep it out of logs, error messages, and API responses.
  • Use a cost factor (12+) that takes ~100–300 ms per hash, and bump it as hardware gets faster.
  • Always await bcrypt.hash/bcrypt.compare (or use the sync variants only in scripts) so the event loop isn’t blocked unexpectedly.
  • Return a single generic error for bad email or bad password to prevent account enumeration.
  • Enforce a sensible minimum password length and reject known-breached passwords rather than imposing arbitrary complexity rules.
  • Re-hash on successful login when the stored cost is outdated or when migrating to Argon2.
  • Serve every auth endpoint over HTTPS so passwords are never sent in cleartext.
Last updated June 14, 2026
Was this helpful?