hwtr-js
JavaScript reference implementation of the HWT protocol. Demos using this at hwt-demo.
canonical documentation issues
Token format: hwt.signature.key-id.expires-unix-seconds.format.payload
Install
// Deno / JSR
import Hwtr from 'jsr:@hwt/hwtr-js'
// Node / Bun via JSR
// npx jsr add @hwt/hwtr-js
// Local
import Hwtr from './hwtr.js'
Quickstart
// Generate keys once — store the result securely
const keyConfig = await Hwtr.generateKeys({ type: 'Ed25519' })
// Create an instance
const hwtr = await Hwtr.factory({}, keyConfig)
// Sign
const token = await hwtr.create({ sub: 'user:123', role: 'editor' })
// Verify
const result = await hwtr.verify(token)
if (result.ok) {
console.log(result.data) // { sub: 'user:123', role: 'editor' }
}
Running tests
deno test --allow-import ./test.js
node --import ./node-esm.js ./test.js
API
Hwtr.factory(options, keyConfig) → Promise<Hwtr>
Preferred constructor. Equivalent to new Hwtr(options).importKeys(keyConfig).
const hwtr = await Hwtr.factory({ expiresInSeconds: 3600 }, keyConfig)
Constructor options — all optional:
| Option | Default | Description |
|---|---|---|
expiresInSeconds |
60 |
Default token lifetime |
maxTokenLifetimeSeconds |
86400 |
Hard ceiling; 0 = unlimited |
leewaySeconds |
1 |
Clock skew tolerance. The spec recommends not exceeding 5 minutes (300s); no maximum enforced. |
maxTokenSizeBytes |
4096 |
Range: 512–16384 |
format |
'j' |
Payload codec |
signatureSize |
0 |
Truncate HMAC signature to N chars; 0 = full; ignored for asymmetric keys |
throwOnInvalid |
false |
Throw instead of returning ok: false |
throwOnExpired |
false |
Throw instead of returning ok: false |
throwOnGenerate |
true |
Throw on signing failure |
throwOnEncoding |
true |
Throw on codec failure |
Key generation
// Complete key set — pass directly to factory or importKeys
const keyConfig = await Hwtr.generateKeys({
count: 2, // number of keys (default: 1)
current: 'primary', // id of the signing key
type: 'Ed25519', // algorithm (default: 'HMAC')
})
// → { current, type, keys: [{ id, created, privateKey, publicKey }] }
// Single HMAC key
const key = Hwtr.generateKey({ id: 'main' })
// → { id, secret, created }
// Single asymmetric pair
const pair = await Hwtr.generateKeyPair({ id: 'k1', type: 'ECDSA-P256' })
// → { id, created, publicKey, privateKey } (base64url SPKI/PKCS8)
Supported algorithms: 'HMAC', 'Ed25519', 'ECDSA-P256', 'ECDSA-P384', 'ECDSA-P521'
HMAC is symmetric — single-service only. Use asymmetric types for cross-service verification. P-521 support varies by environment; test before use.
hwtr.importKeys(keyConfig) → Promise<Hwtr>
Imports signing and/or verification keys. factory() calls this for you.
// keyConfig shape
{
current: 'primary', // id of the key used to sign new tokens
type: 'Ed25519',
keys: [
{ id: 'primary', created: '...', privateKey: 'BASE64URL', publicKey: 'BASE64URL' }
// HMAC: { id, created, secret }
],
publicKeys: { // optional: verification-only keys from other services
'partner-key': 'BASE64URL'
}
}
Multiple keys are supported for rotation. Any key whose id matches a token's kid can verify it. Only the key with id === current signs new tokens.
hwtr.create(payload, hiddenData?) → Promise<string>
Creates a token with the default expiry.
const token = await hwtr.create({ sub: 'user:123' })
// hiddenData is signed but not embedded — verifier must supply the same value
const token = await hwtr.create({ sub: 'user:123' }, { ip: '203.0.113.1' })
Returns '' (or throws if throwOnInvalid) when the token would exceed maxTokenSizeBytes.
hwtr.createWith(expiresInSeconds, payload, hiddenData?) → Promise<string>
Creates a token with a specific lifetime. Throws if it exceeds maxTokenLifetimeSeconds.
const token = await hwtr.createWith(300, { sub: 'user:123', oneTime: true })
hwtr.verify(token, hiddenData?) → Promise<VerifyResult>
Verifies signature and expiry. Does not throw by default.
const result = await hwtr.verify(token)
// result.ok — true if valid
// result.data — decoded payload
// result.expired — true if past expiry
// result.expires — unix seconds
// result.error — present on any failure
result.error values: 'hwt invalid', 'hwt invalid format', 'hwt expired', 'hwt unknown encoding "x"', 'hwt unknown key', 'hwt invalid signature', 'hwt data decoding failed'
hwtr.decode(token) → Promise<{ data, expires, error? }>
Decodes the payload without verifying the signature or expiry. For inspection only.
Cross-service verification
A service that only verifies (no signing) passes publicKeys with no private keys:
const verifyOnly = await Hwtr.factory({}, {
type: 'Ed25519',
keys: [],
publicKeys: { 'auth-key': 'BASE64URL' }
})
Convenience methods for key distribution:
const pub = await hwtr.exportPublicKey('primary') // base64url SPKI string
const all = await hwtr.getPublicKeys() // { id: base64url, ... }
await hwtr.addPublicKey({ id, publicKeyBase64, type }) // add external key at runtime
Codecs
'j' (JSON) is the only built-in codec. Register custom codecs once per process:
Hwtr.registerFormat('name', {
encode(data) { /* → Uint8Array */ },
decode(buffer) { /* → value */ }
})
Hwtr.formats // → ['j', 'name', ...]
Format names: /^[a-zA-Z][a-zA-Z0-9]{1,19}$/. Already-registered names throw.
An extended JSON codec (jx) supporting Date, BigInt, Map, Set, and typed arrays is available in the source repository at hwtr.formats.js — it is not included in the published package.
Utilities
Hwtr.timingSafeEqual(a, b) // constant-time comparison; strings or Uint8Array
Hwtr.bufferToBase64Url(buffer) // ArrayBuffer | Uint8Array → base64url string
Hwtr.base64urlToUint8Array(str) // base64url → Uint8Array; empty array on bad input
Hwtr.textToBase64Url(text) // UTF-8 string → base64url
Hwtr.base64urlToText(str) // base64url → UTF-8 string
Hwtr.isHwt(str) // true if str contains a plausible HWT signature segment
Hwtr.ALGORITHMS // supported algorithm map
Hwtr.version // 0.20250528
All also exported as named exports from hwtr.js.
Related
License
Copyright 2026 Jim Montgomery
SPDX-License-Identifier: Apache-2.0
Apache License 2.0. See LICENSE.