@portable/password
Hashes and verifies passwords with PBKDF2 on WebCrypto. Runs in browsers, Cloudflare Workers, Deno, Bun and Node 24+.
Replaces the PBKDF2 use of node:crypto, pbkdf2 and pbkdf2-password, and hand-written salt:hash formats in app code.
npm install @portable/passwordimport * as Password from '@portable/password';
const stored = await Password.hash(password);
// '$pbkdf2-sha256$i=600000$jL2uY0lkKl3m0WbJ8a7SmA$7m0N...'
if (await Password.verify(password, stored)) {
if (Password.needsRehash(stored)) {
await saveHash(user, await Password.hash(password));
}
}hash derives a 32-byte PBKDF2-HMAC-SHA256 hash with crypto.subtle.deriveBits, 600,000 iterations (OWASP's recommendation) and a random 16-byte salt. It returns a PHC string that stores the algorithm, iterations, salt and hash, so you store one column and can raise the iterations later. The salt and hash are base64 without padding.
API
| Function | Returns | Description |
|---|---|---|
hash(password, options?) | Promise<string> | Hashes a password with a new random salt. |
verify(password, stored, options?) | Promise<boolean> | true if the password matches. Compares in constant time. |
needsRehash(stored, options?) | boolean | true if stored uses fewer iterations than options.iterations, a digest other than SHA-256, a salt under 16 bytes or a hash under 32 bytes. |
password is a string or its bytes as a Uint8Array. A string is normalized to NFC before it is encoded as UTF-8, so 'café' typed as one code point or as e plus a combining accent gives the same hash. Bytes are hashed as they are.
verify resolves to false for a wrong password and rejects for a bad stored string. It reads:
- the PHC format
hashwrites:$pbkdf2-sha256$i=600000$<salt>$<hash>, with an optionall=<length>after the iterations, as Rust'spbkdf2crate can write it; - passlib's
pbkdf2_sha256,pbkdf2_sha512andpbkdf2_sha1($pbkdf2$), which write the iterations withouti=and.instead of+.
The hash length comes from the stored string, so a 20-byte or 64-byte hash from another tool verifies too.
Options
| Option | Functions | Default | Description |
|---|---|---|---|
iterations | hash, needsRehash | 600000 | PBKDF2 iterations to write, or the minimum needsRehash accepts. Cloudflare Workers allows at most 100,000. |
maxBytes | hash, verify | 4096 | Longest password, in UTF-8 bytes after normalization. Infinity turns the check off. |
signal | hash, verify | Rejects with signal.reason when aborted. WebCrypto can't stop a derivation that has started, so it runs to the end in the background. |
Errors
| Error | Thrown when |
|---|---|
InvalidInputError | stored is not a well-formed PBKDF2 hash: a missing field, iterations outside 1 to 2^32 - 1 or with a leading zero, a character outside base64, or padding. |
UnsupportedError | stored is another algorithm's hash, such as $2b$ (bcrypt), $argon2id$ or $scrypt$. |
MaxBytesError | The password is longer than maxBytes. The error's maxBytes property holds the limit. |
A bad iterations or maxBytes option gives a RangeError: needsRehash throws it, and hash and verify reject with it.
Cloudflare Workers
Workers rejects PBKDF2 with more than 100,000 iterations: deriveBits throws a NotSupportedError ("Pbkdf2 failed: iteration counts above 100000 are not supported"). The limit is the default in workerd's IsolateLimitEnforcer, and the comment there says it guards against a request that would run long enough to restart the process. Local wrangler dev and Miniflare run workerd without the limit: 600,000 iterations worked there in 550 ms, so code that passes locally fails once deployed.
On Workers, hash with iterations: 100_000 and check with the same value:
const options = { iterations: 100_000 };
const stored = await Password.hash(password, options);
Password.needsRehash(stored, options); // false100,000 iterations is a sixth of OWASP's recommendation, so a stolen hash costs an attacker six times less to crack. If that's not enough, hash passwords in a service that can run 600,000 iterations or Argon2id, and keep the Worker for the rest. A hash written elsewhere with more than 100,000 iterations can't be verified on Workers at all.
Why use this over node:crypto, pbkdf2 and pbkdf2-password
node:crypto and pbkdf2 give you a key derivation function, and the rest is up to you: a salt, a storage format, a constant-time compare, a plan for raising the iterations. pbkdf2-password adds a salt but defaults to 10,000 SHA-1 iterations and returns the salt and hash separately, and none of them compare. This package does all of it with one string and runs where node:crypto doesn't.
- It hashes at the same speed as
node:crypto'spbkdf2: about 110 ms per hash at 600,000 iterations for both on the benchmark machine, because both call OpenSSL.verifytook as long aspbkdf2followed bytimingSafeEqual. - The stored string records the digest and iterations, so
needsRehashcan tell you when to upgrade a hash andverifykeeps reading old ones. - The compare is constant time. A
===on hex or base64 strings, common in hand-written code, leaks how many leading characters matched. - The string format is the one passlib and Rust's
pbkdf2crate use, so Python and Rust services can share a users table with a JavaScript one. - Passwords are normalized to NFC. passlib and
node:cryptohash the code units they're given, so the same password typed on two keyboards can fail to verify. - A password over 4096 bytes is rejected with
MaxBytesError, as in passlib, before any hashing. - An unknown algorithm name throws
UnsupportedError. pbkdf2 3.1.2 and earlier, in browsers, returned an all-zero key for algorithm names it didn't recognize, such as'SHA256', so every password gave the same key (CVE-2025-6545). - pbkdf2-password and code that calls
node:crypto'spbkdf2store the salt and hash separately. To verify those hashes, build a PHC string from them with the digest and iterations they were made with, and drop the base64 padding: for pbkdf2-password's defaults (SHA-1, 10,000 iterations) that is$pbkdf2$i=10000$<salt>$<hash>. The identifier is$pbkdf2$for SHA-1,$pbkdf2-sha256$for SHA-256 and$pbkdf2-sha512$for SHA-512, and a salt stored as text goes in as the base64 of its UTF-8 bytes.needsRehashreturnstruefor such a string, so rehash on the next login. - Because string passwords are normalized to NFC, a hash made elsewhere from a password in another normalization form verifies only if you pass the original bytes as a
Uint8Array. - passlib's
pbkdf2_sha256can't read thei=form thathashwrites.verifyreads passlib's hashes, so moving from passlib works, but a Python service that keeps reading the table has to split the string on$and callhashlib.pbkdf2_hmacitself.
Other algorithms
WebCrypto has PBKDF2 and no other password hash, so this package can't verify bcrypt, scrypt or Argon2 hashes. Node 24 has an experimental Argon2id in crypto.subtle, but browsers and Workers don't. For those hashes, verify rejects with UnsupportedError, and you can verify them with another library and rehash on the next login:
async function check(user: User, password: string): Promise<boolean> {
try {
return await Password.verify(password, user.hash);
} catch (error) {
if (!(error instanceof Password.UnsupportedError)) {
throw error;
}
if (!(await verifyLegacy(password, user.hash))) {
return false;
}
await saveHash(user, await Password.hash(password));
return true;
}
}@noble/hashes has argon2id and scrypt in plain JavaScript, and hash-wasm has Argon2, scrypt and bcrypt in WebAssembly. Both run in every runtime this package does. bcryptjs and the argon2 package are different algorithms, not PBKDF2. Keep using them if you're already on them and their runtime fits. Argon2id is the stronger choice where you can run it, and PBKDF2 is the one WebCrypto can run everywhere.
| @portable/password | pbkdf2 | pbkdf2-password | node:crypto | crypto.subtle | |
|---|---|---|---|---|---|
| Hash a password with 600,000 iterations | 8.88 | 9.26 | 8.69 | 9.23 | 9.23 |
| Verify a password with 600,000 iterations | 9.33 | – | – | 8.95 | – |
| Check whether a stored hash needs a rehash | 603,000 | – | – | – | – |
Operations per second from pnpm bench, higher is better, in Node 24.22.0, on Intel(R) Xeon(R) Processor @ 2.10GHz (4 cores). A dash means the library has no equivalent.
| @portable/password | pbkdf2 | pbkdf2-password | node:crypto | crypto.subtle | |
|---|---|---|---|---|---|
| Minified and gzipped | 4.2 kB | 22 kB | 1.5 kB | built in | built in |
| Runtime dependencies | 5 | 43 | 2 | – | – |
| Node built-ins used | none | buffer, events, util | Buffer, crypto | Node only | none |
| TypeScript types | included | included | none | – | – |
| Version compared | – | 3.1.7 | 1.2.1 | – | – |
Sizes cover the imports in the usage example, or the equivalent imports of each library, bundled for the browser by scripts/bundle-size.ts. Runtime dependencies count every package installed with the published package. A library that uses Node built-ins needs polyfills in a browser and the nodejs_compat flag in Cloudflare Workers.