Security packages for Node.js that depend on nothing.

Password hashing, encryption, signed tokens, IDs, env loading and rate limiting. Each is its own package, built on node:crypto and nothing else.

$ pnpm add @systemix/hash

Output of 20 real calls, run when the site was built (2026-10-27).

@systemix/hash
$argon2id$v=19$m=19456,t=2,p=1$D+TxuN/RoW7bHkm3aspRQg$K9lOHm4+omc8PMtJ7rf4gOdNLHeqJW2Q0/HZpWuRIgQ
@systemix/cipher
U1gBAmsxA4mEmJ5moJ9zoop3OB2YgeTdoTHOmoTDWFytw1l879UbbEEZMJbJT1a3yR0
@systemix/token
eyJhbGciOiJIUzI1NiIsInR5cCI6IlNUIn0.eyJzdWIiOiJ1c2VyXzEiLCJleHAiOjE3OTMwNzc3NDksImlhdCI6MTc5MzA3Njg0OX0.BnNRyRD-z56_KjuEuLHW_4XwgLGmGDSpXa_w0STzYdI
@systemix/id
01a17bb5-6bce-74a0-87f5-bfb653954c73
8
libraries
0
dependencies in them
458
tests passing
24.7+
Node.js only

Why Systemix

01

Nothing to audit but us

The eight libraries have an empty dependencies list. Everything runs on node:crypto and the standard library, so there is no transitive tree to track CVEs in.

02

Defaults match OWASP

Argon2id at 19 MiB, 2 passes, 1 lane. AES-256-GCM with a fresh random IV per message. You get the recommended setting without choosing it.

03

Old data keeps working

Hashes and ciphertexts from earlier versions still verify and decrypt. needsRehash and needsRotation tell you which ones to upgrade at your own pace.

04

Checked against references

Tests use the RFC 7515 JWS vector, the Argon2id reference vector, and attack tokens built outside the library. 458 tests across the packages.

What the code does

Five packages, run for real. The output beside each snippet is what the code returned when this page was built.

@systemix/hash

Argon2id, in one column

The default is Argon2id at the OWASP minimum. The result is a single PHC string with the algorithm, parameters and salt, so you store one value and can raise the cost later.

login.tsts
const stored = await hashPassword(password);

await verifyPassword(password, stored);
await verifyPassword('wrong guess', stored);

needsRehash(stored);
needsRehash(scryptHash);
stored
$argon2id$v=19$m=19456,t=2,p=1$D+TxuN/RoW7bHkm3aspRQg$K9lOHm4+omc8PMtJ7rf4gOdNLHeqJW2Q0/HZpWuRIgQ
right password
true
wrong password
false
needsRehash(stored)
false
needsRehash(scrypt)
true
@systemix/cipher

Rotate keys without a migration

Each payload carries the id of the key that made it, and that header is authenticated. Add a new current key and old payloads still decrypt.

rotate.tsts
const ring = createKeyring({ keys: { k1 }, current: 'k1' });
const sealed = await ring.encryptString('card ending 4242');

const next = createKeyring({ keys: { k1, k2 }, current: 'k2' });

next.needsRotation(sealed);
await next.decryptString(sealed);
await next.decryptString(tampered); // one character changed
sealed
U1gBAmsxA4mEmJ5moJ9zoop3OB2YgeTdoTHOmoTDWFytw1l879UbbEEZMJbJT1a3yR0
needsRotation
true
decryptString(sealed)
card ending 4242
decryptString(tampered)
DecryptionError: Decryption failed. Wrong key, tampered data, or wrong additional data.
@systemix/token

Refuses what it should refuse

You pick the accepted algorithm; the token never gets to. Short HMAC secrets are rejected, and a swapped payload does not verify.

sign.tsts
const token = await encodeSigned({ sub: 'user_1' }, secret, {
  algorithm: 'HS256',
  expiresIn: 900,
});

await verifySigned(token, secret, { algorithms: ['HS256'] });

// payload swapped, old signature kept
await verifySigned(forged, secret, { algorithms: ['HS256'] });

await encodeSigned({ sub: 'x' }, 'short', { algorithm: 'HS256' });
token
eyJhbGciOiJIUzI1NiIsInR5cCI6IlNUIn0.eyJzdWIiOiJ1c2VyXzEiLCJleHAiOjE3OTMwNzc3NDksImlhdCI6MTc5MzA3Njg0OX0.BnNRyRD-z56_KjuEuLHW_4XwgLGmGDSpXa_w0STzYdI
verify
payload.sub = user_1
verify(forged)
InvalidSignatureError: Invalid token signature
weak secret
InvalidKeyError: HS256 secret must be at least 32 bytes (got 5).
@systemix/rate-limit

One call per request

Fixed window, sliding window or token bucket behind the same async call. Stores are atomic, so a shared store cannot be raced into extra requests.

limit.tsts
const limiter = createRateLimiter({ limit: 3, windowMs: 60_000 });

for (let i = 1; i <= 4; i++) {
  const r = await limiter.limit('203.0.113.7');
  // r.success, r.remaining, r.retryAfter
}
call 1
allowed, 2 left
call 2
allowed, 1 left
call 3
allowed, 0 left
call 4
blocked, retry in 60s
@systemix/id

Time-ordered IDs that stay ordered

UUIDv7 sorts by creation time, which keeps database indexes tight. The monotonic generator keeps IDs from one millisecond in order.

ids.tsts
const next = createMonotonicUuidV7();
const ids = [next(), next(), next()];

uuidV7Timestamp(ids[0]);
ids
01a17bb5-6bce-74a0-87f5-bfb653954c73 01a17bb5-6bce-74a0-87f5-bfb653954c74 01a17bb5-6bce-74a0-87f5-bfb653954c75
sorted as created
true
timestamp
2026-10-27T04:54:09.614Z

Bugs we shipped, and fixed

Before the last release we audited our own packages. These are real defects from the earlier versions, each now covered by a regression test. Release notes.

  • @systemix/rate-limit

    Blocked clients got a fresh allowance

    The cleanup pass deleted token buckets that were empty, so a client that had hit its limit was let back in at the next sweep.

  • @systemix/token

    An RSA public key could sign HS256 tokens

    Verification accepted the public key as an HMAC secret, so anyone who had it could forge a token. Keys and algorithms are now checked against each other.

  • @systemix/env

    A .env file overrode the real environment

    A value in a file beat a variable you had actually set. The real environment now wins.

  • @systemix/passphrase

    The default word list had 10 words

    Four words from it is about 13 bits of entropy. The default is now the EFF list, about 51.7 bits for four words.

Install one, or all of them.

$ pnpm add @systemix/hash @systemix/cipher @systemix/token