A collection of low-level JWT (RFC 7519) utilities using the Web Crypto API. Supports:
- JWS (RFC 7515) — sign and verify tokens using HMAC, RSA, RSA-PSS, ECDSA, and EdDSA algorithms
- JWE (RFC 7516) — encrypt and decrypt data using Direct (
dir), AES Key Wrap, AES-GCM KW, RSA-OAEP, PBES2, and ECDH-ES key management with AES-GCM or AES-CBC+HMAC-SHA2 content encryption - JWK (RFC 7517) — generate, import, export, wrap, and unwrap keys (CryptoKey, JWK, PEM)
- Framework adapters for cookie-based JWT sessions:
Zero runtime dependencies for core. Optional peer deps for adapters (h3, cookie-es, rou3).
# Auto-detect package manager (npm, yarn, pnpm, deno, bun)
npx nypm install unjwtIf you would like to install the agent skill you can run:
npx skills add sandros94/unjwtFull reference: skills/unjwt/references/jws.md
import { sign, verify } from "unjwt/jws";
import { generateJWK } from "unjwt/jwk";
// or import both from the root: import { sign, verify, generateJWK } from "unjwt";
// Generate a key (HMAC, RSA, ECDSA, EdDSA, etc.)
const key = await generateJWK("HS256");
// Sign a JWT
const token = await sign(
{ sub: "user123", role: "admin" },
key,
{ expiresIn: "1h" }, // supports: number (seconds), "30s", "10m", "2h", "7D", "1Y"
);
// Verify and extract payload
const { payload, protectedHeader } = await verify(token, key);
console.log(payload); // { sub: "user123", role: "admin", iat: ..., exp: ... }Asymmetric keys (RS256, ES256, PS256, Ed25519, etc.):
const keys = await generateJWK("RS256");
const token = await sign({ sub: "user123" }, keys.privateKey);
const { payload } = await verify(token, keys.publicKey);Key lookup function for dynamic key resolution:
const { payload } = await verify(
token,
async (header) => {
// Resolve key by kid, alg, or any header field
return await fetchPublicKeyByKid(header.kid);
},
{ algorithms: ["RS256"] },
);JWKSet for key rotation and JWKS endpoint consumption:
When a JWKSet is passed (directly or returned from a lookup function), verify tries each matching key in order until one succeeds. If the token carries a kid, only keys with that exact kid are tried — no retry. If kid is absent, all keys in the set whose alg is compatible become candidates. This makes transparent key rotation possible without any retry logic in userland.
// Fetch all public keys from a JWKS endpoint — library picks the right one
const jwks = await fetch("https://auth.example.com/.well-known/jwks.json").then((r) => r.json());
const { payload } = await verify(token, jwks);
// Rotation window: old tokens (no kid) are tried against all keys automatically
const rotatingSet = { keys: [newKey, legacyKey] };
const { payload: p } = await verify(oldToken, rotatingSet);Full reference: skills/unjwt/references/jwe.md
import { encrypt, decrypt } from "unjwt/jwe";
// or: import { encrypt, decrypt } from "unjwt";
// Password-based encryption (simplest — uses PBES2)
const token = await encrypt({ secret: "sensitive data" }, "my-password");
const { payload } = await decrypt(token, "my-password");With explicit key management:
import { generateJWK } from "unjwt/jwk";
// AES Key Wrap
const aesKey = await generateJWK("A256KW");
const token = await encrypt({ data: "secret" }, aesKey);
// RSA-OAEP
const rsaKeys = await generateJWK("RSA-OAEP-256");
const token2 = await encrypt({ data: "secret" }, rsaKeys.publicKey);
const { payload } = await decrypt(token2, rsaKeys.privateKey);
// ECDH-ES
const ecKeys = await generateJWK("ECDH-ES+A256KW");
const token3 = await encrypt({ data: "secret" }, ecKeys.publicKey);In end-to-end encryption (E2EE), each participant holds a key pair: the private key never leaves the owner's system; the public key is distributed freely. A sender encrypts a message using the recipient's public key, producing a token that only the recipient — holding the matching private key — can decrypt.
Each participant generates a key pair once and persists it. The public key is shared out-of-band — published to a server endpoint, written to a config file, exchanged at registration, etc.:
import { generateJWK } from "unjwt/jwk";
const myKeys = await generateJWK("ECDH-ES+A256KW", { kid: "alice-2025" });
// → { privateKey: JWK_EC_Private, publicKey: JWK_EC_Public }
// Keep myKeys.privateKey in secure storage — never expose it
// Share myKeys.publicKey freely (e.g., publish at /.well-known/jwks.json)For two-party communication, encrypt and decrypt handle everything. Pass the recipient's public key — a one-time ephemeral key pair is generated internally per message:
import { encrypt, decrypt } from "unjwt/jwe";
// Sender (Alice) ————————————————————————————————————
// Fetch Bob's public key (from his public endpoint, shared config, etc.)
const bobPublicKey = /* ... */;
const token = await encrypt({ message: "Hello Bob!" }, bobPublicKey);
// Send `token` to Bob over any channel — only Bob can decrypt it
// Recipient (Bob) ————————————————————————————————————
const { payload } = await decrypt(token, bobPrivateKey);
console.log(payload.message); // "Hello Bob!"Each token carries a fresh ephemeral public key (epk) in its header. Bob uses that epk together with his private key to re-derive the same shared secret Alice used, then unwraps the Content Encryption Key. At no point does Alice have Bob's private key, and Bob cannot forge a message appearing to come from Alice.
Simple — one token per recipient. Encrypt the payload independently for each party. Each token is decryptable only by its intended recipient:
const recipients = [
{ name: "bob", publicKey: bobPublicKey },
{ name: "charlie", publicKey: charliePublicKey },
];
const tokens = await Promise.all(
recipients.map(({ publicKey }) => encrypt({ message: "Hello everyone!" }, publicKey)),
);
// Deliver tokens[0] to Bob, tokens[1] to CharlieThis is the right choice for most use cases. The only trade-off is proportional size: N recipients produce N tokens.
Shared ciphertext — one payload, N wrapped keys. When the payload is large or bandwidth matters, encrypt the payload once with a random Content Encryption Key (dir), then wrap that key separately per recipient. Everyone receives the same ciphertext, each with their own individually wrapped key:
import { wrapKey, unwrapKey } from "unjwt/jwk";
import { randomBytes } from "unjwt/utils";
import { encrypt, decrypt } from "unjwt/jwe";
const enc = "A256GCM";
// 1. Generate a random CEK once for this message (32 bytes = 256-bit for A256GCM)
const cek = randomBytes(32);
// 2. Encrypt the payload ONCE using the CEK directly (alg: "dir")
const ciphertext = await encrypt({ message: "Hello everyone!" }, cek, { alg: "dir", enc });
// 3. Wrap the CEK individually for each recipient
const wrappedKeys = await Promise.all(
recipients.map(async ({ name, publicKey }) => {
const { encryptedKey, epk } = await wrapKey("ECDH-ES+A256KW", cek, publicKey);
return { name, encryptedKey, epk };
}),
);
// Deliver `ciphertext` (same for all) + each recipient's own { encryptedKey, epk }
// Recipient side ——————————————————————————————————————————————
const mine = wrappedKeys.find((w) => w.name === myName)!;
const rawCek = await unwrapKey("ECDH-ES+A256KW", mine.encryptedKey, myPrivateKey, {
format: "raw",
epk: mine.epk,
enc,
});
const { payload } = await decrypt(ciphertext, rawCek);
console.log(payload.message); // "Hello everyone!"This pattern is the manual foundation of JWE JSON Serialization (RFC 7516 §3.3), which packages the ciphertext and all wrapped keys into a single
recipients[]JSON structure. Full JWE JSON Serialization support is planned for a future release;wrapKeyandunwrapKeyprovide the building blocks for those who need to construct or consume the JSON form today.
wrapKey and unwrapKey already handle the full ECDH Concat KDF + AES-KW cycle internally. deriveSharedSecret exposes the KDF step alone, returning the raw derived bytes before any key-wrapping step:
import { deriveSharedSecret } from "unjwt/jwk";
// Both sides independently derive the exact same bytes
const aliceView = await deriveSharedSecret(
bobPublicKey,
aliceEphemeralPrivateKey,
"ECDH-ES+A256KW",
);
const bobView = await deriveSharedSecret(aliceEphemeralPublicKey, bobPrivateKey, "ECDH-ES+A256KW");
// aliceView and bobView are identical Uint8ArraysUse deriveSharedSecret when you need the shared secret itself rather than a wrapped key: custom hybrid protocols that use the derived bytes directly, non-standard wrapping schemes, or verifying the key agreement step in isolation.
Full reference: skills/unjwt/references/jwk.md
import {
generateKey,
generateJWK,
importKey,
exportKey,
importFromPEM,
exportToPEM,
wrapKey,
unwrapKey,
deriveKeyFromPassword,
getJWKsFromSet,
deriveSharedSecret,
configureJWKCache,
} from "unjwt/jwk";
// All of the above are also importable from "unjwt" directly.
// Generate keys as CryptoKey or JWK
const hmacKey = await generateKey("HS256"); // CryptoKey
const rsaPair = await generateKey("RS256"); // CryptoKeyPair
const ecJwk = await generateJWK("ES256", { kid: "k1" }); // { privateKey: JWK, publicKey: JWK }
// PEM conversion
const jwk = await importFromPEM(pemString, "spki", "RS256", { jwkParams: { kid: "rsa-1" } });
const pem = await exportToPEM(jwk, "spki");
// Key wrapping
const cek = crypto.getRandomValues(new Uint8Array(32));
const { encryptedKey } = await wrapKey("A256KW", cek, aesKey);
const unwrapped = await unwrapKey("A256KW", encryptedKey, aesKey, { format: "raw" });
// ECDH-ES shared secret (e.g. for multi-recipient JWE)
const secret = await deriveSharedSecret(
recipientPublicKey,
senderEphemeralPrivate,
"ECDH-ES+A256KW",
);Full reference: skills/unjwt/references/utils.md
import {
base64UrlEncode,
base64UrlDecode,
randomBytes,
isJWK,
isJWKSet,
isSymmetricJWK,
isPrivateJWK,
isPublicJWK,
isCryptoKey,
textEncoder,
textDecoder,
} from "unjwt/utils";
// Most of the above are also importable from "unjwt" directly.
const bytes = randomBytes(32);
const encoded = base64UrlEncode(bytes);
const decoded = base64UrlDecode(encoded, false); // Uint8ArrayFull reference: skills/unjwt/references/adapters-h3.md
Cookie-based JWT session management for H3 applications. Sessions are stored as encrypted (JWE) or signed (JWS) tokens in chunked cookies.
Note: Sessions are lazy —
session.idisundefineduntilsession.update()is called. This is intentional for OAuth/spec-compliant flows where sessions should only be created upon valid operations.
import { useJWESession, generateJWK } from "unjwt/adapters/h3v2";
app.get("/profile", async (event) => {
const session = await useJWESession(event, {
key: process.env.SESSION_SECRET!, // password string, symmetric JWK, or asymmetric keypair
maxAge: "7D",
});
if (!session.id) {
// No active session — create one
await session.update({ userId: "123", email: "user@example.com" });
}
return { user: session.data };
});import { useJWSSession, generateJWK } from "unjwt/adapters/h3v2";
const keys = await generateJWK("RS256"); // persist these!
app.get("/preferences", async (event) => {
const session = await useJWSSession(event, {
key: keys,
maxAge: "24h",
});
return { theme: session.data.theme };
});- Cookie chunking — large tokens are automatically split across multiple cookies
- Header-based tokens — read from
Authorization: Bearer <token>or custom headers viasessionHeader - Lifecycle hooks —
onRead,onUpdate,onClear,onExpire,onErrorfor logging, refresh logic, etc. - Key lookup hooks —
onUnsealKeyLookup(JWE) /onVerifyKeyLookup(JWS) for key rotation - TypeScript generics — strongly-typed session data via
useJWESession<MyData>(event, config)
import {
type SessionConfigJWE,
type SessionConfigJWS,
useJWESession,
useJWSSession,
getJWESession,
updateJWSSession,
generateJWK,
} from "unjwt/adapters/h3v2";
const atKeys = await generateJWK("RS256");
const refreshConfig = {
key: process.env.REFRESH_SECRET!,
name: "refresh_token",
} satisfies SessionConfigJWE;
const accessConfig = {
key: atKeys,
name: "access_token",
maxAge: "15m",
hooks: {
async onExpire({ event, config }) {
const refresh = await getJWESession(event, refreshConfig);
if (refresh.data.sub) {
await updateJWSSession(event, config, {
sub: refresh.data.sub,
scope: refresh.data.scope,
});
}
},
},
} satisfies SessionConfigJWS;local development
Originally developed by Johann Schopplich. Heavily inspired by Filip Skokan's work.
Published under the MIT license.
Made by community 💛
🤖 auto updated with automd