mrkeyoor.com_
Thu 06 Aug 13:51 UTC
npmSecurityupdated 06 Aug 2026

bcryptjs

bcryptjs is the bcrypt password hashing algorithm reimplemented in plain JavaScript, with no native addon and no dependencies. You call bcrypt.hash(password, 10) and get back a 60 character string that contains the algorithm version, the cost, the salt and the digest, so one text column stores everything and bcrypt.compare(password, hash) can check it later without you tracking a salt. Because it is pure JavaScript it installs anywhere Node runs, including serverless bundles, Alpine images, Electron, React Native, Cloudflare Workers, and the browser, which is the whole reason it exists: the popular bcrypt package is a C++ addon that has to compile or find a prebuilt binary for your exact platform and Node version, and bcryptjs never has that problem. The output is byte-compatible with that native binding, so hashes made by one verify with the other. Version 3 ships an ES module with a UMD fallback, includes TypeScript types, and generates $2b$ hashes by default instead of $2a$.

Verdict

The right bcrypt when a native addon is not an option, and it has been correct and compatible for over a decade. If you are picking a password hash from scratch rather than matching existing hashes, pick Argon2id instead and keep this in mind for the migration path.

API stability5/5hash, compare, genSalt, and their Sync variants have not changed since 2014 and version 3 kept every one of them. The only breaks in a decade were packaging: an ESM default with a UMD fallback, dist/ removed, and the $2a$ to $2b$ prefix switch, none of which affect verifying an existing hash
Docs4/5One README that covers install, sync, async, callback, and CLI usage, plus a complete function-by-function API list with types, and an explicit section on the 72 byte limit and the pure JavaScript speed penalty. There is no separate docs site, no guidance on choosing a cost factor, and no worked migration example from version 2 to version 3
Maintenance3/5Pushed 2026-07-20 with 1 open issue out of 5 open issues and PRs, which is a genuinely clean tracker. The counterweight is cadence: nothing shipped between 2.4.3 in 2017 and 3.0.0 in February 2025, and the project is one maintainer. For a finished algorithm that is defensible, but a security bug would depend on one person being available
Ecosystem4/5About 12.8M downloads a week and it is the default suggestion in most Node authentication tutorials, with drop-in compatibility with the native bcrypt binding so switching either direction needs no data migration. It sits inside a shrinking niche, though, since new projects are steered toward Argon2 and the interesting work is happening in the Rust-backed packages

Use it if

  • Native addons are a problem for you: a serverless bundle, an Alpine or distroless image, a Lambda layer, Electron, or any deploy where node-gyp compiling against the wrong Node ABI is a recurring incident
  • You need to verify existing bcrypt hashes and cannot change the scheme, whether they came from the native bcrypt package, Rails, Django, PHP's password_hash, or Spring Security
  • You want zero dependencies in a security-relevant path: bcryptjs installs one package with no transitive tree, which is one fewer supply chain to watch
  • You run in a JavaScript environment where addons are impossible, such as a browser, a Cloudflare Worker, Deno Deploy, or React Native
  • Your login volume is low enough that a 30 percent slower hash does not matter, which for most applications means anything short of hundreds of authentications per second
Skip it if

Setup reality

npm install bcryptjs and there is nothing else: no dependencies, no build step, no node-gyp, types included. Version 3 changed the packaging in ways worth knowing before you upgrade. The package is now type: module with an exports map, so import bcrypt from 'bcryptjs' resolves to the ESM build and require('bcryptjs') resolves to a UMD build under umd/; both work, but a bundler configured with an old alias to bcryptjs/dist/bcrypt.js will break because dist/ no longer exists. Version 3 also generates $2b$ hashes where version 2 generated $2a$, which changes nothing for verification but breaks any test that compares a freshly generated hash against a stored literal. In a browser using the ESM build directly you have to stub out the crypto import, usually with an import map, though bundlers drop it automatically. The bigger operational point is that every synchronous call blocks: bcrypt.hashSync(pw, 12) is roughly a quarter of a second during which your Node process serves nobody, so use the promise API, which splits the work into chunks and yields to the event loop between them. That still costs the same CPU, it just stops one login from freezing every other request. Pick the cost by measuring on the machine that will run it, not by copying a number, and remember the pure JavaScript penalty means your cost 12 takes longer than someone else's cost 12.

Patterns

Hash on signup, compare on loginhash-and-compare

import bcrypt from "bcryptjs";

// signup: 10 is the library default, 12 is a common production choice
const hash = await bcrypt.hash(password, 12);
await db.users.insert({ email, passwordHash: hash });

// login
const user = await db.users.findByEmail(email);
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) return unauthorized();

The second argument to hash is the cost, not a salt: the salt is generated for you and embedded in the 60 character result, so there is no second column to store. compare returns a boolean and does not throw on a wrong password, which is the opposite of several Python libraries and worth checking if you are porting. It does throw if the stored value is not a valid bcrypt hash, so wrap it if that column can hold anything else.

Reject passwords bcrypt would silently truncateseventy-two-byte-truncation

import bcrypt from "bcryptjs";

if (bcrypt.truncates(password)) {
  throw new BadRequest("Password is too long (max 72 bytes).");
}
const hash = await bcrypt.hash(password, 12);

// what happens if you do not check:
const long = "a".repeat(72);
const h = await bcrypt.hash(long + "-real-secret-tail", 10);
await bcrypt.compare(long + "-anything-else", h);   // true

bcrypt hashes at most 72 bytes and this library does not enforce it, on purpose, so that its behaviour matches the native binding. That means a user with a long passphrase is protected only by its first 72 bytes, and UTF-8 makes the boundary hard to eyeball since one emoji is four bytes. truncates() does the byte-length check for you. Do not pre-hash with SHA-256 to work around this unless you also base64 the digest: raw binary can contain a NUL, and bcrypt stops there.

Sync blocks everything, async yieldsavoid-sync-on-a-server

import bcrypt from "bcryptjs";

// blocks the event loop for the whole hash: no other request is served
const h1 = bcrypt.hashSync(password, 12);

// splits the work into chunks and yields between them
const h2 = await bcrypt.hash(password, 12);
const ok = await bcrypt.compare(password, stored);

// callback form, still non-blocking
bcrypt.hash(password, 12, (err, hash) => { /* ... */ });

The async API is not multi-threaded. It does the same work on the same thread, pausing between chunks so other requests can run, which fixes latency for everyone else but does not make the login itself faster. Sync is fine in a one-shot script or a CLI and is a self-inflicted outage in a request handler. If you need real parallelism, that is a worker thread or a native binding, not this.

Measure the cost factor on real hardwarechoose-a-cost

import bcrypt from "bcryptjs";

for (const rounds of [10, 11, 12, 13, 14]) {
  const t = performance.now();
  await bcrypt.hash("benchmark-password", rounds);
  console.log(rounds, Math.round(performance.now() - t) + "ms");
}

// read the cost back out of an existing hash
bcrypt.getRounds("$2b$12$K2CtDP7zSGOKgjXjxD9vJ." /* ... */);   // 12

Cost is a power of two, so each extra round doubles the time. Run this on the instance type you deploy to, not your laptop, and pick the highest value whose latency you can live with under concurrency; pure JavaScript is roughly 30 percent slower than the native binding, so your ceiling is a round or so lower than the numbers people quote. Setting cost too high turns your login endpoint into a denial of service target, since each attempt costs the server far more than the attacker.

Raise the cost of old hashes as users sign inrehash-on-login

import bcrypt from "bcryptjs";

const TARGET_ROUNDS = 12;

async function login(email, password) {
  const user = await db.users.findByEmail(email);
  if (!user) return null;
  if (!(await bcrypt.compare(password, user.passwordHash))) return null;

  if (bcrypt.getRounds(user.passwordHash) < TARGET_ROUNDS) {
    const upgraded = await bcrypt.hash(password, TARGET_ROUNDS);
    await db.users.update(user.id, { passwordHash: upgraded });
  }
  return user;
}

Login is the only point where you hold the plaintext, so it is the only place a cost upgrade can happen without a forced password reset. Do it after a successful compare, never before. getRounds throws on a string that is not a bcrypt hash, so guard it if the column also holds Argon2 or legacy values.

Do not leak which emails existconstant-time-login

import bcrypt from "bcryptjs";

// a real hash of a value nobody can submit, computed once at startup
const DUMMY_HASH = bcrypt.hashSync(crypto.randomUUID(), 12);

async function authenticate(email, password) {
  const user = await db.users.findByEmail(email);
  const hash = user?.passwordHash ?? DUMMY_HASH;
  const ok = await bcrypt.compare(password, hash);
  return ok && user ? user : null;
}

Without the dummy compare, an unknown email returns in a millisecond and a known one takes a few hundred, which enumerates your user table over any network. Build DUMMY_HASH once at module load, using hashSync there because startup is the one place blocking is free. Keep the ok && user check: the dummy can never match, but writing it explicitly stops a later refactor from returning a truthy value.

Import it from either module systemcommonjs-and-esm

// ESM
import bcrypt from "bcryptjs";
import { hash, compare, genSalt } from "bcryptjs";

// CommonJS, resolves to the UMD build
const bcrypt = require("bcryptjs");

// what no longer exists in v3
// require("bcryptjs/dist/bcrypt.js")   <- dist/ was removed

Version 3 declares type: module with an exports map: import gets index.js, require gets umd/index.js, and each has its own type definitions. Deep paths into dist/ were valid in version 2 and are gone, which is the usual reason an upgrade breaks a webpack alias or a Jest moduleNameMapper rather than the application code. In a browser loading the ESM build straight from a CDN, stub the crypto import with an import map; bundlers strip it on their own.

Generate the salt explicitly when you need toseparate-salt-generation

import bcrypt from "bcryptjs";

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

// inspect an existing hash
bcrypt.getSalt(hash);     // '$2b$12$K2CtDP7zSGOKgjXjxD9vJ.'
bcrypt.getRounds(hash);   // 12

Passing a number to hash generates the salt internally, which is what you want almost always; the two-step form only matters when you need the same salt twice, such as reproducing a hash in a test fixture. Never reuse a salt across users, and never derive one from the username: the whole point is that two identical passwords produce different hashes. getSalt does not validate its input, so it will happily return nonsense for a non-bcrypt string.

Move off bcrypt without resetting passwordsmigrate-to-argon2

import bcrypt from "bcryptjs";
import * as argon2 from "@node-rs/argon2";

async function verifyAndUpgrade(user, password) {
  if (user.passwordHash.startsWith("$argon2")) {
    return argon2.verify(user.passwordHash, password);
  }
  if (!(await bcrypt.compare(password, user.passwordHash))) return false;

  const next = await argon2.hash(password);      // Argon2id defaults
  await db.users.update(user.id, { passwordHash: next });
  return true;
}

Dispatching on the hash prefix is the whole migration: $2a$, $2b$, and $2y$ are bcrypt, $argon2 is the new scheme. Users convert as they log in, and after a year you force a reset on whoever is left. Keep bcryptjs installed until that column has no $2 prefixes left, and check the prefix rather than the length, since both formats are fixed-width strings and easy to confuse.

Report progress for slow hashes in a UIprogress-callback

import bcrypt from "bcryptjs";

bcrypt.hash(
  password,
  14,
  (err, hash) => {
    if (err) return showError(err);
    submit(hash);
  },
  (percent) => {
    progressBar.value = percent;   // 0.0 to 1.0
  },
);

The progress callback fires at most once every 100ms and only exists on the callback form, not the promise form. It is meant for a browser or Electron context where a high cost factor takes long enough that the user needs feedback. On a server it is noise, and reaching for it is usually a sign that the cost factor is set higher than the hardware supports.

Understand setRandomFallback before you touch itrandom-fallback

import bcrypt from "bcryptjs";

// only for environments with neither Web Crypto nor node:crypto
bcrypt.setRandomFallback((len) => {
  const out = new Array(len);
  // must be cryptographically secure and properly seeded
  for (let i = 0; i < len; i++) out[i] = secureByteFromSomewhere();
  return out;
});

By default the library uses Web Crypto or node:crypto and needs nothing from you. If you see the error about no random implementation, the fix is almost always to give the runtime a real CSPRNG, not to install one here: a fallback built on Math.random produces guessable salts, which quietly removes the protection bcrypt exists to provide. Treat this as a compatibility shim for exotic embedded JavaScript engines.

Generate a hash without writing a scripthash-from-the-cli

$ npx bcryptjs "my-admin-password" 12
$2b$12$4Rn7...

$ npx bcryptjs "my-admin-password"        # cost defaults to 10
$ npx bcryptjs "pw" '$2b$12$K2CtDP7zSGOKgjXjxD9vJ.'   # explicit salt

Handy for seeding an admin account or a fixture without a scratch file. The password ends up in your shell history and in the process list, so do not do this with a real credential on a shared machine, and rotate anything you generate this way before it reaches production.

Alternatives

PackageRegistryPick it when
@node-rs/argon2npmYou are picking a password hash fresh and want the memory-hard algorithm OWASP recommends, with prebuilt Rust binaries instead of node-gyp
@node-rs/bcryptnpmYou must stay on bcrypt for compatibility but want native speed without a compiler in your build image
bcryptnpmYou are on a long-lived Node server, already have node-gyp working, and want the reference C++ binding
argon2npmYou want Argon2 from the widely used C++ binding and can live with a native build step