# bcrypt.js: bcrypt in pure JavaScript, and the 72 byte trap nobody checks

> A zero dependency bcrypt implementation that runs in Node and the browser with the same API as the C++ binding, at roughly 30 percent of its speed and with one silent truncation footgun.

**dcodeIO/bcrypt.js** — Optimized bcrypt in JavaScript with zero dependencies, with TypeScript support.

- Repository: https://github.com/dcodeIO/bcrypt.js
- Stars: 3,797 · Forks: 290
- Language: JavaScript
- License: NOASSERTION
- Published: 2026-10-06 · Updated: 2026-10-06 · Language: en
- Canonical page: https://hysenlabs.com/projects/dcodeio-bcrypt-js

## Pure JavaScript bcrypt, in Node and the browser

The README opens with the whole proposition in one line: optimized bcrypt in JavaScript with zero dependencies, with TypeScript support, compatible to the C++ bcrypt binding on Node.js and also working in the browser. Every clause of that sentence is a decision someone had to make.

Zero dependencies is the one that shapes the package. There is nothing to audit, no native toolchain, no prebuild binaries that fail on a platform nobody tested, and nothing to keep in sync with a parent project. The tree is correspondingly small: `index.js`, `index.d.ts`, `types.d.ts`, a `umd/` directory with the CommonJS and browser build, a `bin/bcrypt` CLI, `scripts/` for the build, and `tests/`.

The package metadata makes the module story precise. It is `"type": "module"` with an `exports` map that points `import` at `./index.js` with `./index.d.ts` types, and `require` at `./umd/index.js` with its own declaration file. That split of type definitions was the subject of the 3.0.1 release in February 2025. The repository is dcodeIO/bcrypt.js with 3797 stars, 290 forks and only 6 open issues, BSD 3-Clause, on `main`, last pushed on 2026-09-06.

## Thirty percent slower is a security parameter, not a benchmark line

This is the part of the README most people skim and should not. The package states that while bcrypt.js is compatible to the C++ bcrypt binding, it is written in pure JavaScript and thus slower, about 30 percent according to a benchmark linked from their wiki, and that this effectively reduces the number of iterations that can be processed in an equal time span.

That is the correct way to frame it. bcrypt's cost control is the iteration count, and the iteration count you can afford depends on your hardware and your latency budget. If pure JavaScript costs 30 percent more per iteration, then at the same target response time you run fewer iterations, and your hashes are cheaper to brute force. The number you pick should be based on measured throughput on your own deployment target rather than copied from a tutorial.

On the other side, the native binding needs a compiler or prebuilt binaries, and it does not run in a browser at all. So the choice is not 'worse library' versus 'better library', it is throughput versus reach. For a Node service on infrastructure you control, native wins. For a client-side build, an edge worker runtime, or a CI pipeline that refuses native modules, there is no alternative on the table.

## The 72 byte limit is not enforced, and that is the sharpest edge

The README documents three concrete facts in the security section: the maximum input length is 72 bytes, UTF-8 encoded characters can use up to four bytes each, and generated hashes are 60 characters long.

Then it says the important thing: maximum input length is not implicitly checked by the library for compatibility with the C++ binding on Node.js, but should be checked with `bcrypt.truncates(password)` where necessary.

This is a silent data-loss bug waiting to happen. bcrypt only considers the first 72 bytes of input. If a user sets a 100 character passphrase, bcrypt hashes the first 72 and ignores the rest, with no error. A user who types their password slightly differently beyond byte 72 still authenticates successfully. That behaviour is inherited from the original algorithm, but a JavaScript library that skips the check without warning is asking for a security review finding.

The fix is one call to bcrypt.truncates(password), and the README documents it as the check to apply wherever truncation matters.

The API reference describes that function as testing whether a password will be truncated when hashed, meaning its length is greater than 72 bytes when converted to UTF-8. Because multi-byte characters count in bytes rather than characters, a 30 character password made entirely of emoji or CJK text can exceed the limit while looking short.

## Sync, promise and callback forms for every operation

The API is complete enough that migration from the native binding is a matter of changing the import. Every operation comes in three forms. `genSaltSync`, `hashSync` and `compareSync` block. `genSalt`, `hash` and `compare` return promises. And the same three accept callbacks for older codebases.

```ts
const salt = bcrypt.genSaltSync(10);
const hash = bcrypt.hashSync("B4c0/\/", salt);
bcrypt.compareSync("B4c0/\/", hash); // true
bcrypt.compareSync("not_bacon", hash); // false
```

The async form is the one to reach for on a server, and the README explains why it does not block:

```ts
const salt = await bcrypt.genSalt(10);
const hash = await bcrypt.hash("B4c0/\/", salt);
await bcrypt.compare("B4c0/\/", hash); // true
```

Under the hood, asynchronous APIs split an operation into small chunks, and after each chunk completes the next is placed on the back of the JS event queue. That is what makes it viable to run a deliberately expensive function in a Node event loop without stalling every other request. There is also a `ProgressCallback` receiving the fraction of rounds completed, called at most once per 100 milliseconds, which is useful for reporting progress on a long hash.

Hash introspection is there too: `getRounds(hash)` returns the cost factor baked into a hash and `getSalt(hash)` extracts the salt portion without validating it. Both matter when you are migrating existing users and need to raise their iteration count on next login rather than invalidating their password.

## Browser usage needs one stub, CDN paths need a version

Running this in a browser takes one extra step, and the README is precise about it. Because the ESM variant references `crypto`, you need to stub that import, for example with an import map. Bundlers are said to omit it automatically, so this mostly matters for direct ESM use from a CDN.

There is a `RandomFallback` hook for the same reason. The signature is a function taking a length and returning a number array, called to obtain random bytes when both the Web Crypto API and Node.js crypto are unavailable. That is the fallback path of last resort, and the fact that it exists as a documented public API tells you the maintainers thought about hostile environments.

For CDN loads, the README lists jsDelivr paths for GitHub by tag and for npm by version, in both ESM and UMD form, and unpkg equivalents. It then says to replace TAG or VERSION with a specific release, or omit it to use latest, which is not recommended in production. Pinning the version matters more here than in most packages because the CDN file is the only thing standing between you and a compromised dependency.

There is also a command line entry point declared as a `bin` named `bcrypt`, with usage documented as `bcrypt <input> [rounds|salt]`. It is a small convenience for generating a hash from a terminal without writing a script.

## Releases since 2025 have been fixes, which is the right pace

Three releases are published, and all three are single-line bug fixes. Version 3.0.1 in February 2025 separated ESM and UMD type definitions, which is a real fix for TypeScript users on mixed module setups. Version 3.0.2 the following day used an upstream fix to emit interop helpers. Version 3.0.3 in November 2025 made the async versions always yield to the event loop before calling nextTick.

That last one is the most interesting, and it describes exactly the failure mode the README's chunking note describes. Yielding is what keeps a pure JavaScript bcrypt from becoming the slowest thing in your event loop, so a regression there is worth a patch release.

A package with three patch releases and no feature additions is the right shape for a cryptographic primitive. The algorithm does not change, the attack surface does not change, and the API surface is complete. What changes is compatibility with new Node runtimes and module resolution, which is what these three releases were about.

The test setup reflects the same conservatism. `npm test` runs unit tests through plain `node tests` and then type tests across four TypeScript configurations: esnext, nodenext, commonjs and global. A library whose whole value is drop-in compatibility with the native binding has to prove it against every way of importing, and that matrix is the proof.

## Conclusion

bcrypt.js earns its place when a native module is not an option: a browser bundle, a Cloudflare Worker, a Lambda with no compilation step, or a codebase that refuses native dependencies in CI. The API mirrors the C++ binding closely enough that swapping between them is uneventful, and the async version genuinely yields to the event loop instead of blocking it. Two things to weigh. It is about 30 percent slower than the native implementation, which in practice means fewer iterations for the same wall clock budget, and it does not check the 72 byte input limit for you, so call bcrypt.truncates before hashing anything a user typed. If you have a native toolchain available, the original binding remains the faster choice. If you do not, this is the version to reach for, and the package is BSD 3-Clause with no runtime dependencies at all.

## FAQ

### What is BcryptJS used for?

It hashes and verifies passwords using the bcrypt algorithm, and it does so in pure JavaScript with zero dependencies. The API mirrors the native C++ bcrypt binding on Node.js, so you can swap between them with an import change, and it also runs in the browser where the native binding cannot. The README positions it for environments with no native toolchain, such as bundlers, edge runtimes and browser builds.

### Is bcrypt still safe?

The README argues yes on the basis of adaptivity. Beyond incorporating a salt to protect against rainbow table attacks, bcrypt is an adaptive function where the iteration count can be raised over time to make it slower, which keeps it resistant to brute-force search as computing power grows. The catch is that a pure JavaScript implementation is slower, so fewer iterations fit in the same time budget, which is why the cost factor should be chosen from measured throughput.

### Which is better, bcrypt or BcryptJS?

They are the same algorithm, and the choice is about the runtime rather than the cryptography. The native bcrypt binding is faster because it is compiled, so it lets you afford more iterations. bcrypt.js runs anywhere JavaScript does, in a browser or an edge worker, with no native toolchain and no dependencies, at roughly 30 percent the speed. Use native on a Node server you control and bcrypt.js where native code is not available.

### Which is better, bcrypt or SHA-256?

bcrypt, because it is built for passwords. A general purpose hash like SHA-256 is fast, and speed is the wrong property for password storage because it makes brute-force search cheap. bcrypt is deliberately slow and has an adjustable iteration count, so an attacker with more hardware cannot simply scale their guesses. bcrypt.js provides that property in JavaScript, with the 72 byte input limit that comes with the algorithm.

## Sources

- [dcodeIO/bcrypt.js on GitHub](https://github.com/dcodeIO/bcrypt.js)
- [Issues](https://github.com/dcodeIO/bcrypt.js/issues)
- [README](https://github.com/dcodeIO/bcrypt.js/blob/main/README.md)
- [Releases](https://github.com/dcodeIO/bcrypt.js/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/dcodeio-bcrypt-js
