npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@notifycode/hash-it

v1.0.2

Published

Password hashing (scrypt/pbkdf2/Argon2id) and asymmetric token signing for Node.js and Bun — payment-network-style private/public key separation, zero runtime dependencies

Readme

@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.

Tests Coverage Node TypeScript Zero deps


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 — plus an opt-in raw-key fast path that skips PBKDF2 entirely (v1.0.2) | | Session management | Access + refresh token pairs, with rotation | | API tokens | Opaque, prefixed tokens (myapp_.eyJ...) with embedded verifiable claims | | Device/session fingerprinting | Deterministic, HMAC-keyed fingerprints with constant-time verification (fingerprint / verifyFingerprint) | | Zero dependencies | Node.js crypto only. Bun implements it compatibly — nothing extra to install there either | | Timing-safe | Constant-time comparison throughout password/token/fingerprint/CBC-MAC 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 (minimum 16) | | saltLength | scrypt/pbkdf2 | 16 | Salt length in bytes (minimum 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, and the integrity check is constant-time.

key can be a string (password — PBKDF2-stretched) or a raw 32-byte Buffer (used directly, skipping PBKDF2 — see Performance below).

.open(encrypted, key)

const plaintext = hashit.encrypt.open(sealed, 'my-encryption-key');

key must match the type used to seal() (same string, or a Buffer with the same bytes).

.generateKey()

const key = hashit.encrypt.generateKey(); // random 256-bit Buffer
const sealed = hashit.encrypt.seal('data', key);
const plaintext = hashit.encrypt.open(sealed, 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 (also accepts a raw number of seconds)

// Device/session fingerprinting — deterministic and verifiable (see below)
const fp = hashit.utils.fingerprint('ua:chrome,ip:1.2.3.4', 'sha256', process.env.FINGERPRINT_SECRET);
const ok = hashit.utils.verifyFingerprint('ua:chrome,ip:1.2.3.4', fp.fingerprint, {
  secret: process.env.FINGERPRINT_SECRET,
});

fingerprint(data, algorithm?, secret?) is deterministic: the same data (and secret, if used) always produces the same output, so it can be stored and later recomputed for comparison via verifyFingerprint(). Pass a stable secret (e.g. from an environment variable) for an HMAC-keyed fingerprint that's unforgeable without knowing that secret; omit it for a simpler, unkeyed hash that's reproducible but not forgery-resistant. Always compare with verifyFingerprint(), never === — it does the comparison in constant time.


Performance

Two things are worth knowing if you're calling hash-it on a hot path:

scrypt (password hashing) is deliberately slow. That's the entire point of a memory-hard KDF — it's what makes brute-forcing expensive. Use the non-blocking hashit.password.hash/.verify (they run on libuv's threadpool) rather than the synchronous named exports in a live server.

hashit.encrypt.seal/.open with a string key are also deliberately slow — a string key is treated as a password and stretched via PBKDF2-SHA512 at 100,000 iterations on every single call (twice, for CBC mode: once for the encryption key, once for the MAC key). That's correct and necessary when your input really is a human password, but if you're encrypting many small values back-to-back with the same key, that KDF cost dominates the actual AES operation.

If you already manage a proper high-entropy 256-bit key (issued by a KMS, or generated once via hashit.encrypt.generateKey()), pass it as a Buffer instead of a string:

// Slow path (correct for real passwords): PBKDF2-stretched on every call
const sealed = hashit.encrypt.seal('data', 'a-human-password');

// Fast path (correct for an already-strong key): used directly, no PBKDF2
const key = hashit.encrypt.generateKey(); // or your own 32-byte Buffer from a KMS
const sealed2 = hashit.encrypt.seal('data', key);

This is purely additive — nothing about the existing string-key behavior changes, and ciphertexts sealed under earlier versions still decrypt correctly with the same string key.


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, generateEncryptionKey,
  createSession, rotateSession,
  fingerprint, verifyFingerprint,
} 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 224 tests
npm run test:watch    # watch mode
npm run typecheck     # strict tsc --noEmit against the full project
npm run lint           # eslint
npm run format:check  # prettier --check

Coverage: ~97% statements/lines, ~89% branches, 100% functions (all enforced via coverageThreshold in jest.config.mjs; CI runs the full lint/typecheck/test/build pipeline on Node 18.x and 20.x — see .github/workflows/ci.yml).


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.