Skip to content
Node.js nd libraries 5 min read

bcrypt: Password Hashing Library

Passwords must never be stored in plain text or with a fast general-purpose hash like SHA-256 — both leave users exposed if the database leaks. bcrypt is a password-hashing function deliberately designed to be slow and to embed a per-password salt, which defeats rainbow tables and makes brute-force attacks expensive. The Node.js bcrypt library wraps a native C++ implementation behind a tiny API: you hash a password on signup and compare a submitted password against the stored hash on login. This page covers salt rounds (the work factor), the async and sync APIs, the structure of a bcrypt hash, and wiring it into a real signup/login flow.

Installing and hashing a password

bcrypt runs on any maintained Node.js release; Node 20 or 22 LTS is the sensible default. Install it from npm. It ships native bindings, so the install compiles or downloads a prebuilt binary for your platform.

npm install bcrypt

The core operation is bcrypt.hash(password, saltRounds). It returns a promise resolving to a single string that packs the algorithm version, the cost, the salt, and the digest together — you store that one string and nothing else.

import bcrypt from "bcrypt";

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

const hash = await bcrypt.hash(plain, saltRounds);
console.log(hash);

Output:

$2b$12$eImiTXuWVxfM37uY4JANjQ.4iZ8.dM0K3lQwR2sP9wB3vY7Qe2H5O

That output decomposes as $2b$ (the bcrypt variant), 12 (the cost), the next 22 characters (the salt), and the remaining characters (the hash). Because the salt is baked in, you do not store or manage salts separately — compare reads them back out automatically.

Salt rounds and the work factor

The second argument to hash is the cost factor, expressed as a power of two: 12 means 2^12 = 4096 key-expansion rounds. Each increment doubles the time to compute a hash, which is exactly the point — hashing should be slow enough to frustrate attackers but fast enough not to harm legitimate logins. As hardware improves, you raise the cost.

Salt roundsRelative costTypical use
10BaselineLow-latency or high-volume auth
12~4x slower than 10Recommended default for most apps
14~16x slower than 10High-security, lower login volume

Aim for a cost where a single hash takes roughly 200-400ms on your production hardware. Benchmark on the actual server: a value that feels fine on a laptop may be far too slow under concurrent load.

You can also generate a salt explicitly with bcrypt.genSalt(rounds) and pass it to hash, but passing the rounds directly is simpler and equivalent.

const salt = await bcrypt.genSalt(12);
const hash = await bcrypt.hash(plain, salt);

Comparing a password on login

Never hash the login attempt and compare strings yourself — the salt is random, so two hashes of the same password never match. Instead use bcrypt.compare(plain, storedHash), which extracts the salt and cost from the stored hash, re-derives the digest, and returns a boolean.

const ok = await bcrypt.compare("correct horse battery staple", hash);
console.log(ok); // true

const wrong = await bcrypt.compare("hunter2", hash);
console.log(wrong); // false

Output:

true
false

compare runs in constant time relative to the hash, which helps avoid timing side channels when checking a password.

Async vs. sync APIs

Every function comes in two forms: promise-returning (hash, compare, genSalt) and blocking (hashSync, compareSync, genSaltSync). Hashing is CPU-intensive; the async variants offload the work to libuv’s thread pool so the event loop stays free to handle other requests. The sync variants block the entire process until they finish.

APIBehaviorWhen to use
await bcrypt.hash(pw, 12)Runs on the thread pool, non-blockingServers and any request path
bcrypt.hashSync(pw, 12)Blocks the event loopOne-off scripts, CLI seeders
await bcrypt.compare(pw, h)Non-blockingLogin handlers
bcrypt.compareSync(pw, h)Blocks the event loopTests, scripts

Never call the *Sync functions inside a web request handler. A single hashSync at cost 12 freezes your server for hundreds of milliseconds, stalling every concurrent connection.

Integrating into a signup and login flow

In practice you hash during registration and compare during authentication. The handlers below use Express-style request/response objects; the bcrypt calls are the same regardless of framework.

import bcrypt from "bcrypt";
import { createUser, findUserByEmail } from "./users.js";

const SALT_ROUNDS = 12;

// Signup: hash before persisting, store only the hash.
export async function signup(req, res) {
  const { email, password } = req.body;

  if (await findUserByEmail(email)) {
    return res.status(409).json({ error: "Email already registered" });
  }

  const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
  const user = await createUser({ email, passwordHash });

  return res.status(201).json({ id: user.id, email: user.email });
}

// Login: look up the user, then compare against the stored hash.
export async function login(req, res) {
  const { email, password } = req.body;
  const user = await findUserByEmail(email);

  // Same generic message whether the email or password is wrong,
  // so attackers cannot probe which emails exist.
  const valid = user && (await bcrypt.compare(password, user.passwordHash));
  if (!valid) {
    return res.status(401).json({ error: "Invalid credentials" });
  }

  // Hand off to your session or token layer here.
  return res.json({ id: user.id, email: user.email });
}

Note that when the user is missing, we short-circuit the && — but for stricter timing-attack resistance you can run a dummy bcrypt.compare against a throwaway hash so failed lookups take the same time as real ones.

Best practices

  • Use a cost factor of at least 12 and revisit it periodically as hardware speeds up.
  • Always use the async hash/compare in request paths; reserve the *Sync variants for scripts and tests.
  • Store only the bcrypt hash string — never the plain password, and never a separate salt column.
  • Verify logins with bcrypt.compare, not by re-hashing and comparing strings yourself.
  • Return an identical error for “unknown email” and “wrong password” to avoid leaking which accounts exist.
  • bcrypt silently truncates input beyond 72 bytes; for very long passphrases, pre-hash with SHA-256 before bcrypt, or validate length on input.
  • Keep cost factors and other tunables in environment variables so you can raise them without a code change.
Last updated June 14, 2026
Was this helpful?