@notifycode/hash-it
Password hashing and asymmetric token signing for Node.js and Bun — payment-network-style private/public key separation, real native cryptography, zero runtime dependencies.
Why this exists
hash-it is built around one specific, real idea: payment-network-style key separation. Systems like Mastercard's token service don't verify anything with a shared secret — a private key signs, and any number of services holding only the public key can verify a token without ever being able to forge one. No shared secret means no single point of compromise across every service that needs to check a token.
hash-it implements that architecture pattern faithfully:
┌─────────────────┐ ┌──────────────────────┐
│ Token Issuer │ │ Token Verifier(s) │
│ (private key │ token │ (public key only — │
│ only) │ ───────► │ can verify but │
│ │ │ cannot forge) │
└─────────────────┘ └──────────────────────┘
The private key never leaves the issuer. Verification also checks that the actual key type (EC vs. RSA) matches the algorithm the token claims — it doesn't just trust the header.
What this genuinely is NOT (yet): that architecture pattern is necessary for payment-grade security, but it is not sufficient on its own. Mastercard's actual security also rests on tamper-resistant hardware that never lets a private key exist outside it, a formal PKI with certificate revocation, independent adversarial security audits, and compliance certification (PCI-DSS, FIPS 140). hash-it does not have those.
hash-it tokens are also not JWT. They're structurally similar — header.payload.signature, base64url-encoded JSON — because that's a sensible, well-understood shape. But the header's typ is "hash-it", not "JWT", and verification is not spec-compliant with JOSE/JWT. This is deliberate: a hash-it token will never be silently accepted by a generic JWT library, and a JWT will never be silently accepted by hash-it. If you need interoperability with jsonwebtoken, jose, Auth0, or any other JWT-consuming system, use a JWT library instead.
Features
| Password hashing | scrypt (default, real, memory-hard, native crypto), PBKDF2 (fallback), Argon2id (Bun-native only, opt-in) |
| Non-blocking | hashit.password.hash/verify use the libuv threadpool (or Bun's worker thread for Argon2id) — safe to call from a live request handler |
| Asymmetric tokens | ECDSA P-256/P-384/P-521, RSA-4096 (RS256/RS512/PS256) — hash-it's own format, not JWT |
| Key rotation | Multi-key PublicKeySet — rotate without invalidating tokens issued under the previous key |
| Async key generation | generateKeyPairAsync — RSA-4096 generation off the event loop |
| Encryption | AES-256-GCM (default, AEAD), AES-256-CBC (Encrypt-then-MAC with independently derived keys), ChaCha20-Poly1305 |
| Session management | Access + refresh token pairs, with rotation |
| API tokens | Opaque, prefixed tokens (myapp_.eyJ...) with embedded verifiable claims |
| Zero dependencies | Node.js crypto only. Bun implements it compatibly — nothing extra to install there either |
| Timing-safe | Constant-time comparison throughout password/token verification |
Installation
npm install @notifycode/hash-it
# or
bun add @notifycode/hash-it
# or
pnpm add @notifycode/hash-it
Requires Node.js ≥ 18, or Bun (any recent version).
Quick Start
import { hashit } from '@notifycode/hash-it';
// 1. Hash a password — scrypt by default, non-blocking
const { hash } = await hashit.password.hash('user-password');
const { valid, needsRehash } = await hashit.password.verify('user-password', hash);
// 2. Generate a key pair
const keyPair = hashit.keys.generate(); // sync — fine for ES256, slow-ish for RSA
const rsaKeyPair = await hashit.keys.generateAsync({ algorithm: 'RS256' }); // non-blocking
// 3. Sign and verify a token (hash-it's own format — see note above on JWT)
const token = hashit.token.sign(
{ sub: 'user_123', role: 'admin' },
{ privateKey: keyPair.privateKey, expiresIn: '15m' }
);
const result = hashit.token.verify(token, { publicKey: keyPair.publicKey });
// 4. Session management
const session = hashit.session.create(keyPair, { sub: 'user_123' });
// 5. API tokens
const apiToken = hashit.apiToken.generate(keyPair, { prefix: 'myapp_', expiresIn: '90d' });
API Reference
All functionality is accessible via the hashit object:
import { hashit } from '@notifycode/hash-it';
hashit.password
.hash(password, options?)
Hash a password. Default algorithm is scrypt — real, native, memory-hard, zero dependencies on both Node.js and Bun. Non-blocking.
const result = await hashit.password.hash('my-password');
// result.hash — e.g. '$hashit-scrypt$v1$N=65536,r=8,p=1$...'
// result.algorithm — 'scrypt'
// result.salt — base64url-encoded salt
// result.timingMs — milliseconds taken
Options:
| Option | Applies to | Default | Description |
|---|---|---|---|
algorithm |
— | 'scrypt' |
'scrypt' | 'pbkdf2' | 'argon2id' (Bun only) |
costFactor |
scrypt | 65536 |
CPU/memory cost (N) — must be a power of two |
blockSize |
scrypt | 8 |
Block size (r) |
parallelization |
scrypt | 1 |
Parallelization (p) |
iterations |
pbkdf2 | 600000 |
PBKDF2 iteration count |
digest |
pbkdf2 | 'sha512' |
PBKDF2 HMAC digest |
memoryCost |
argon2id | 19456 |
Memory cost in KiB (Bun only) |
timeCost |
argon2id | 2 |
Time cost / iterations (Bun only) |
hashLength |
scrypt/pbkdf2 | 32 |
Output length in bytes |
saltLength |
scrypt/pbkdf2 | 16 |
Salt length in bytes (min 12) |
// Explicit fallback algorithm
const result = await hashit.password.hash('my-password', { algorithm: 'pbkdf2' });
// Real Argon2id — only works on Bun, throws a clear error otherwise
const result = await hashit.password.hash('my-password', { algorithm: 'argon2id' });
.verify(password, hash)
Verify a password. Non-blocking, constant-time. Handles scrypt, pbkdf2, and Bun-native argon2id hashes based on the hash's own prefix — you don't need to specify which algorithm was used.
const { valid, needsRehash, timingMs } = await hashit.password.verify('my-password', storedHash);
if (valid && needsRehash) {
const { hash: newHash } = await hashit.password.hash('my-password');
await db.updateUserHash(userId, newHash);
}
This never throws — including for a hash that requires Bun's native Argon2id when you're not running on Bun. In that case it fails closed (valid: false, needsRehash: true) rather than crashing whatever called it.
.needsRehash(hash, options?)
Check whether a stored hash was created with weaker-than-current parameters, or a deprecated algorithm.
if (hashit.password.needsRehash(storedHash)) {
// rehash on next successful login
}
hashit.keys
.generate(options?)
Generate an asymmetric key pair. Synchronous — blocks the event loop, which matters for RSA-4096. Fine for CLIs, startup code, or offline key rotation; use .generateAsync() in a live server.
const keyPair = hashit.keys.generate(); // ECDSA P-256 (default)
const keyPair2 = hashit.keys.generate({ algorithm: 'ES384' });
const rsaKeyPair = hashit.keys.generate({ algorithm: 'RS256' }); // RSA-4096
const namedKey = hashit.keys.generate({ algorithm: 'ES256', kid: 'key-2024-01' });
Supported algorithms: ES256, ES384, ES512, RS256, RS512, PS256
.generateAsync(options?)
Same output shape, but non-blocking — offloads to libuv's threadpool. Use this in a request handler or during live key rotation.
const keyPair = await hashit.keys.generateAsync({ algorithm: 'RS256' });
.exportPublic(keyPair)
const publicEntry = hashit.keys.exportPublic(keyPair);
// { kid, algorithm, publicKey, createdAt } — no private key included
.buildKeySet(entries[])
const keySet = hashit.keys.buildKeySet([
hashit.keys.exportPublic(currentKey),
hashit.keys.exportPublic(previousKey), // kept during rotation window
]);
hashit.token
hash-it's own signed-token format — not JWT, see the note at the top of this document.
.sign(payload, options)
const token = hashit.token.sign(
{ sub: 'user_123', role: 'admin', org: 'acme' },
{
privateKey: keyPair.privateKey,
kid: keyPair.kid, // key ID in header, needed for rotation
algorithm: 'ES256', // default
expiresIn: '15m', // '30s', '15m', '1h', '7d', '2w', or seconds
issuer: 'auth-service',
audience: 'api-service',
}
);
.verify(token, options)
Verifies the signature, confirms the actual key type matches the declared algorithm, and validates claims. Never throws.
const result = hashit.token.verify(token, {
publicKey: keyPair.publicKey, // string, or a PublicKeySet for rotation
issuer: 'auth-service', // optional — checked if provided
audience: 'api-service', // optional — checked if provided
algorithms: ['ES256', 'ES384'], // optional — restrict allowed algorithms
clockSkew: 30, // seconds of tolerance (default: 30)
});
if (result.valid) {
console.log(result.payload?.sub, result.kid, result.algorithm);
} else {
console.log(result.error);
}
.decode(token)
Decodes without verifying the signature. Never use this for authentication — debugging/metadata only.
const payload = hashit.token.decode(token); // UNSAFE — no signature check
hashit.session
.create(keyPair, options)
const session = hashit.session.create(keyPair, {
sub: 'user_123',
issuer: 'auth-service',
accessExpiresIn: '15m', // default
refreshExpiresIn: '7d', // default
claims: { role: 'admin', org: 'acme' },
});
// session.accessToken / refreshToken / accessExpiresAt / refreshExpiresAt / tokenType ('Bearer')
.verify(token, publicKey, options?)
const { valid, payload } = hashit.session.verify(session.accessToken, keyPair.publicKey);
.rotate(refreshToken, keyPair, options)
const newSession = hashit.session.rotate(session.refreshToken, keyPair, { sub: 'user_123' });
hashit.apiToken
Opaque tokens with embedded, verifiable claims — similar in spirit to GitHub's ghp_ or Stripe's sk_ prefixes, but the payload is a real signed hash-it token underneath.
.generate(keyPair, options?)
const apiToken = hashit.apiToken.generate(keyPair, {
prefix: 'myapp_', // default: 'hsh_'. Any length — no 8-character limit.
sub: 'org_123',
expiresIn: '90d', // or null for non-expiring
claims: { scopes: ['read', 'write'] },
});
// apiToken.token — 'myapp_.eyJhbGciOiJFUzI1NiJ9...'
// apiToken.prefix — 'myapp_'
// apiToken.masked — 'myapp_.****3a1b' — safe for logs
// apiToken.expiresAt — Unix timestamp or null
The . right after the prefix is a real delimiter, not a guess — any prefix length works.
.verify(token, publicKey)
const { valid, payload } = hashit.apiToken.verify(apiToken.token, keyPair.publicKey);
.mask(token)
hashit.apiToken.mask('hsh_.eyJhbGciOiJFUzI1NiJ9.abc123');
// → 'hsh_.****c123'
hashit.encrypt
.seal(plaintext, key, options?)
const sealed = hashit.encrypt.seal('sensitive-data', 'my-encryption-key');
// { ciphertext, iv, tag, algorithm: 'aes-256-gcm' }
| Option | Default | Choices |
|---|---|---|
algorithm |
aes-256-gcm |
aes-256-gcm, aes-256-cbc, chacha20-poly1305 |
CBC mode derives its encryption key and its HMAC integrity key independently from your input key — they are not the same secret reused twice.
.open(encrypted, key)
const plaintext = hashit.encrypt.open(sealed, 'my-encryption-key');
hashit.utils
hashit.utils.randomBytes(32); // CSPRNG random bytes, base64url
hashit.utils.safeEqual(a, b); // constant-time string comparison
hashit.utils.parseDuration('15m'); // → 900
hashit.utils.fingerprint('ua:chrome,ip:1.2.3.4'); // HMAC-based fingerprint for device/session binding
Key Rotation
// 1. Generate a new key pair
const newKey = hashit.keys.generate({ kid: 'key-2025-01' });
// 2. Build a key set with both old and new keys
const keySet = hashit.keys.buildKeySet([
hashit.keys.exportPublic(newKey),
hashit.keys.exportPublic(oldKey), // keeps validating tokens signed before rotation
]);
// 3. Sign new tokens with the new key
const token = hashit.token.sign(payload, { privateKey: newKey.privateKey, kid: newKey.kid });
// 4. Verify against the key set — works for tokens from either key
const result = hashit.token.verify(token, { publicKey: keySet });
// 5. Once the refresh window has passed, drop the old key from the set
Tree-Shakeable Named Exports
import {
hashPassword, verifyPassword, hashPasswordAsync, verifyPasswordAsync,
signToken, verifyToken,
generateKeyPair, generateKeyPairAsync, buildKeySet,
seal, open,
createSession, rotateSession,
} from '@notifycode/hash-it';
Error Handling
Errors extend HashItError with a structured code:
import { HashItError, HashItErrorCode } from '@notifycode/hash-it';
try {
const keyPair = await hashit.keys.generateAsync({ algorithm: 'RS256' });
} catch (err) {
if (err instanceof HashItError) {
console.log(err.code); // e.g. HashItErrorCode.INVALID_KEY
}
}
Error codes: INVALID_KEY, INVALID_TOKEN, TOKEN_EXPIRED, TOKEN_NOT_YET_VALID, SIGNATURE_INVALID, ALGORITHM_MISMATCH, KEY_NOT_FOUND, AUDIENCE_MISMATCH, ISSUER_MISMATCH, ENCRYPT_FAILED, DECRYPT_FAILED, HASH_FAILED, INVALID_PARAMS, UNSUPPORTED_RUNTIME
UNSUPPORTED_RUNTIME is thrown only by hashit.password.hash(pw, { algorithm: 'argon2id' }) when called outside Bun — an explicit request for something unavailable gets a clear error, rather than a silent downgrade.
Testing
npm test # run all 199 tests
npm run test:watch # watch mode
Coverage: 97%+ statements/lines, 88%+ branches, 100% functions.
Security
See SECURITY.md for the responsible disclosure process, threat model, and cryptographic primitives in use. See CHANGELOG.md for what changed and why, release to release — including anywhere a past claim in this README turned out to be wrong.