@systemix/token

A cryptographically secure token generator and signed-token module for API keys, session tokens, CSRF tokens, and auth. Zero external dependencies, built on Node.js built-ins. Requires Node.js 24.7+. HMAC and RSA work in both environments.

Installation

ts
pnpm add @systemix/token
ts
npm install @systemix/token

Package Structure

SubpathContents
@systemix/tokenMain entry — re-exports token, signed, rsa, common
@systemix/token/tokenToken generator, encoding utils, validation
@systemix/token/signedSigned token encode/decode/verify
@systemix/token/rsaRSA sign/verify (Node)
@systemix/token/commonCrypto, enums, types, errors, utils

Token Generator

generateToken(props?)

Generates cryptographically secure random tokens. Returns a single string or array of strings.

ts
import { generateToken } from '@systemix/token';

// Default: 32 bytes, hex encoding
const token = generateToken();
// → "a1b2c3d4e5f6789..."

// With options
const apiKey = generateToken({
  byteLength: 32,
  charset: 'hex',
});

const sessionToken = generateToken({
  byteLength: 24,
  charset: 'base64url',
});

const batch = generateToken({
  byteLength: 16,
  charset: 'alphanumeric',
  count: 5,
});
// → ["Ab3xYz...", "Mn7pQr...", ...]

Props

PropertyTypeDefaultDescription
byteLengthnumber32Number of random bytes (1–1024). Output length depends on charset.
charset'hex' | 'base64' | 'base64url' | 'alphanumeric''hex'Encoding format.
countnumber1Number of tokens to generate (1–10). Returns string[] when > 1.

Charset behavior

CharsetOutput lengthUse case
hexbyteLength × 2API keys, opaque IDs
base64~byteLength × 4/3Compact tokens
base64url~byteLength × 4/3, URL-safeURLs, cookies
alphanumericbyteLengthHuman-readable, no special chars

generateTokenPropValidation(props)

Validates props before generation. Throws if invalid.

ts
import { generateTokenPropValidation } from '@systemix/token/token';

try {
  generateTokenPropValidation({ byteLength: 64, charset: 'hex' });
} catch (e) {
  console.error(e.message);
}

Encoding Utilities

Use when you need to encode raw bytes yourself:

ts
import {
  bytesToHex,
  bytesToBase64,
  bytesToBase64Url,
  bytesToAlphanumeric,
} from '@systemix/token/token';

const bytes = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]);

bytesToHex(bytes); // → "48656c6c6f"
bytesToBase64(bytes); // → "SGVsbG8="
bytesToBase64Url(bytes); // → "SGVsbG8"
bytesToAlphanumeric(bytes); // → "NkF2bB2"
FunctionInputOutput
bytesToHex(bytes)Uint8ArrayHex string
bytesToBase64(bytes)Uint8ArrayBase64 string
bytesToBase64Url(bytes)Uint8ArrayURL-safe base64
bytesToAlphanumeric(bytes)Uint8ArrayA–Z, a–z, 0–9

Signed Tokens

Signed tokens are compact, URL-safe strings with a header, payload, and signature. Use for auth, sessions, or any signed claims. Supports HS/RS/PS/ES/EdDSA with node:crypto.

encodeSigned(payload, secret, options?)

Creates a signed token. Returns a Promise<string>. Supports HMAC (shared secret) and RSA (PEM private key).

ts
import { encodeSigned } from '@systemix/token/signed';

// HMAC (shared secret of 32+ bytes)
const token = await encodeSigned(
  { userId: '123', role: 'admin' },
  'my-secret-key',
  {
    algorithm: 'HS256',
    expiresIn: 3600,
    issuer: 'my-app',
    audience: 'api',
    subject: 'user-123',
    tokenId: true,
  },
);

// RSA (PEM private key, 2048+ bits)
const tokenRsa = await encodeSigned({ userId: '123' }, privateKeyPem, {
  algorithm: 'RS256',
  expiresIn: 3600,
});

Encode options

OptionTypeDefaultDescription
algorithmSignedAlgorithm'HS256'Signing algorithm.
typstring'ST'Token type in header.
kidstring—Key ID for key rotation.
ctystring—Content type.
expiresInnumber—Expiration in seconds from now.
notBeforenumber—Not-before in seconds from now.
issuedAtnumbernowIssued-at timestamp.
issuerstring—iss claim.
subjectstring—sub claim.
audiencestring | string[]—aud claim.
tokenIdstring | true—jti claim. true = auto-generate.

decodeSigned(token)

Decodes a token without verifying the signature.

ts
import { decodeSigned } from '@systemix/token/signed';

const { header, payload, signature } = decodeSigned<{ userId: string }>(token);

console.log(header.alg); // "HS256"
console.log(payload.userId); // "123"

verifySigned(token, secret, options)

Decodes and verifies a token. algorithms is required to prevent algorithm confusion.

ts
import { verifySigned } from '@systemix/token/signed';

const payload = await verifySigned<{ userId: string }>(token, 'my-secret', {
  algorithms: ['HS256'],
  issuer: 'my-app',
  audience: 'api',
  clockTolerance: 60,
});

Verify options

OptionTypeRequiredDescription
algorithmsSignedAlgorithm[]YesAllowed algorithms. Prevents algorithm-swap attacks.
typstringNoRequired typ header (case-insensitive).
maxAgenumberNoReject tokens older than N seconds since iat.
issuerstring | string[]NoExpected iss.
audiencestring | string[]NoExpected aud.
subjectstringNoExpected sub.
clockTolerancenumberNoSeconds of skew for exp/nbf.
ignoreExpirationbooleanNoSkip exp check.
ignoreNotBeforebooleanNoSkip nbf check.

Algorithms

AlgorithmTypeKey
HS256, HS384, HS512HMAC (symmetric)Secret of at least 32 / 48 / 64 bytes (RFC 7518 §3.2)
RS256, RS384, RS512RSASSA-PKCS1-v1_5PEM private key (encode) / public key (verify), ≥ 2048 bits
PS256, PS384, PS512RSASSA-PSSSame as RS*; preferred for new RSA systems
ES256, ES384, ES512ECDSAEC key on P-256 / P-384 / P-521; raw r||s signatures
EdDSAEd25519 / Ed448Ed25519 or Ed448 key

Keys are PEM strings or node:crypto KeyObjects. The library refuses keys that don't fit the algorithm: a PEM can't be an HMAC secret (the RS256→HS256 downgrade), RSA keys under 2048 bits and EC keys on the wrong curve are rejected, and so are short HMAC secrets. These throw InvalidKeyError.

Standard claims

ClaimTypeDescription
issstringIssuer
substringSubject
audstring | string[]Audience
expnumberExpiration (Unix seconds)
nbfnumberNot before (Unix seconds)
iatnumberIssued at (Unix seconds)
jtistringToken ID

Common Utilities

Low-level crypto primitives and constant-time comparison:

ts
import {
  getRandomBytes,
  getRandomInt,
  bytesEqual,
  secureCompare,
} from '@systemix/token/common';

const bytes = getRandomBytes(32);
const n = getRandomInt(100); // [0, 100)
bytesEqual(a, b); // constant-time byte comparison
secureCompare(sig1, sig2); // constant-time string comparison
FunctionDescription
getRandomBytes(length)Returns Uint8Array of random bytes.
getRandomInt(max)Returns random integer in [0, max).
bytesEqual(a, b)Constant-time byte comparison (timing-safe).
secureCompare(a, b)Constant-time string comparison (timing-safe).

Common Module

For advanced use: enums, types, errors, and utils.

ts
import {
  CHARSETS,
  HMAC_ALGORITHMS,
  RSA_ALGORITHMS,
  TokenPropsEnum,
  type Charset,
  type SignedAlgorithm,
} from '@systemix/token/common';

Error Handling

Token generator errors

Throws generic Error with messages like:

  • Invalid byteLength. Must be a positive number.
  • Invalid charset. Must be one of: hex, base64, base64url, alphanumeric.

Signed token errors

All extend SignedTokenError:

ErrorWhen
InvalidTokenErrorMalformed token, invalid encoding
InvalidSignatureErrorSignature mismatch, wrong secret
TokenExpiredErrorexp in the past
NotBeforeErrornbf in the future
AudienceMismatchErroraud does not match
IssuerMismatchErroriss does not match
ts
import {
  verifySigned,
  TokenExpiredError,
  InvalidSignatureError,
} from '@systemix/token/signed';

try {
  const payload = await verifySigned(token, secret, { algorithms: ['HS256'] });
} catch (e) {
  if (e instanceof TokenExpiredError) {
    console.log('Expired at', e.expiredAt);
  } else if (e instanceof InvalidSignatureError) {
    console.log('Invalid signature');
  }
}

Usage Examples

API key generation

ts
const apiKey = generateToken({
  byteLength: 32,
  charset: 'hex',
});

Session token with signed payload

ts
const sessionToken = await encodeSigned(
  { userId: user.id, email: user.email },
  process.env.SESSION_SECRET!,
  { expiresIn: 86400, issuer: 'my-app' },
);

const payload = await verifySigned(sessionToken, process.env.SESSION_SECRET!, {
  algorithms: ['HS256'],
  issuer: 'my-app',
});

RSA for distributed verification

ts
const token = await encodeSigned({ sub: 'user-1' }, privateKey, {
  algorithm: 'RS256',
  expiresIn: 3600,
});

const payload = await verifySigned(token, publicKey, {
  algorithms: ['RS256'],
});

Key rotation with kid

ts
const keys = { '2026-10': currentSecret, '2026-04': previousSecret };

const token = await encodeSigned({ sub: 'u1' }, currentSecret, {
  kid: '2026-10',
});

await verifySigned(token, (header) => keys[header.kid], {
  algorithms: ['HS256'],
  typ: 'ST',
});

Compared with jose

jose is the full JOSE toolkit. This package is a small, opinionated JWS signer and verifier: use it when you want a compact API with safe defaults. Use jose when you need more.

@systemix/tokenjose
Signed tokens (JWS compact)YesYes
Encrypted tokens (JWE)NoYes
JWK / JWKS handling and remote JWKSNoYes
RuntimesNode.js 24.7+Node, browsers, edge runtimes
DependenciesNoneNone

Tokens produced here are standard compact JWS, so jose and other libraries can verify them.

Security Notes

  • Algorithm whitelist: algorithms is required, and the token's own alg is never trusted.
  • Key confusion: a key only works with an algorithm of its own type (see Algorithms) — an RSA public key can't be used as an HMAC secret.
  • Secret strength: HMAC secrets must be at least as long as the hash output (32 / 48 / 64 bytes); shorter ones are rejected.
  • Explicit typing: pass typ to verifySigned so a token minted for one purpose can't be accepted for another (RFC 8725 §3.11).
  • Hardening: tokens with a crit header are rejected, payloads must be JSON objects, signatures must be canonical base64url, and a token is expired at its exact exp second.
  • Key rotation: pass a function as the key to pick it by kid (see below). Treat the header as untrusted — use it only to look a key up in a set you control.
  • Clock skew: Use clockTolerance when servers may have time drift.
  • Sensitive data: Signed tokens are signed, not encrypted. Do not put secrets in the payload.

Try it

Open the Token Generator demo to experiment interactively.