sdxc

[ Identity & security ]

@sdxc/jwt

JWT payload classes and the keys that sign them

npm add @sdxc/jwt
pnpm add @sdxc/jwt
yarn add @sdxc/jwt
bun add @sdxc/jwt
Depends on
jose
Used by
auth-saas, blog

JWT payload classes and the ES256, RS256 and EdDSA keys that sign them.

Each kind of token is a class: the registered claims arrive as typed accessors, a subclass adds its own, and signing, verifying and decoding all hand back an instance of that class. The JWK namespace covers a key's whole life: generating, storing, importing, publishing as a JWKS and resolving someone else's JWKS for verification.

Installation

npm add @sdxc/jwt

Signing and verification run on jose, and duration strings for the time claims come from @sdxc/duration; both install with the package. Three entry points: @sdxc/jwt (JWT, JWK, KeyStorage), @sdxc/jwt/algorithm (the algorithm names alone) and @sdxc/jwt/r2 (key storage over a Cloudflare R2 bucket).

Usage

Describe a kind of token

Subclass JWT and read each claim the token carries through this.parser, which type-checks it.

import { JWT } from "@sdxc/jwt";

export class IdToken extends JWT {
	override get subject() {
		return this.parser.string("sub");
	}

	get email() {
		return this.parser.string("email");
	}
}

Issue a token

JWK.signingKeys loads the keys from storage, minting an ES256 key the first time. Time claims accept a duration measured from now.

import { JWK } from "@sdxc/jwt";
import { createR2KeyStorage } from "@sdxc/jwt/r2";
import { env } from "cloudflare:workers";

import { IdToken } from "./id-token.js";

let keys = await JWK.signingKeys(createR2KeyStorage(env.KEYS));

let token = new IdToken({
	iss: "https://auth.example.com",
	sub: "user-123",
	aud: "client-abc",
	email: "ada@example.com",
	iat: "0s",
	exp: "1h",
});

let signed = await token.sign(JWK.Algorithm.ES256, keys);

Publish the key set

let jwks = JWK.toJSON(keys);

return Response.json(jwks); // served at /.well-known/jwks.json

Verify a token

Point a resolver at the issuer's JWKS once and keep it; verify answers an instance of the class it is called on.

import { JWK } from "@sdxc/jwt";

import { IdToken } from "./id-token.js";

let keys = await JWK.importRemote(new URL("https://auth.example.com/.well-known/jwks.json"));

let idToken = await IdToken.verify(rawToken, keys, {
	issuer: "https://auth.example.com",
	audience: "client-abc",
	algorithms: [JWK.Algorithm.ES256],
});

idToken.subject; // "user-123"
idToken.email; // "ada@example.com"

API

JWT

A claim set with the registered claims as accessors. Use it directly, or subclass it per kind of token.

new JWT(payload?: JWT.PayloadInput)

Wraps a claim set, {} by default. exp, iat and nbf take seconds since the epoch or a duration string ("1h") measured from construction; payload holds the resolved seconds. A claim with no accessor still reads by name (null when absent), and assigning an unknown property writes it into the payload.

jwt.payload

The resolved claim set the accessors read and sign signs.

jwt.parser

Protected, for subclass accessors: has(key), get(key), string(key), number(key), boolean(key) and object(key) (a parser over a nested object). Every read but has throws when the claim is absent or holds another JSON type, so guard an optional claim with has.

Registered claim accessors

AccessorClaimTypeWritable
audienceaudstring | string[] | nullyes
expirationTimeexpnumber | nullyes
expiresInexpnumber | nullno
expiresAtexpDate | nullno
expiredexpbooleanno
issuedAtiatDate | nullyes
issuerissstring | nullyes
idjtistring | nullyes
notBeforenbfDate | nullyes
subjectsubstring | nullyes

Absent claims read null, and setting null drops the claim. expiresIn is the seconds left (negative once past), and expired is true once it reaches zero; verify is what enforces expiry.

jwt.sign(algorithm: JWK.Algorithm, jwks: JWK.SigningKey[]): Promise<string>

Signs with the first key generated for algorithm, writes its id as the header kid, and answers the compact token. Throws when no key matches. JWT.sign(jwt, algorithm, jwks) is the static form.

jwt.toJSON(): Record<string, unknown>

The getters declared on the instance's own class, as a plain object.

JWT.verify(token: string, jwks: JWK.VerificationKeys, options?: JWT.VerifyOptions): Promise<JWT>

Checks the signature, exp, nbf and any issuer/audience given, and answers an instance of the calling class; any failure throws. The key is picked per token by its kid, key type and algorithm, so a set can hold several keys. Pass algorithms to accept only the ones you expect.

JWT.decode(token: string): JWT

Reads the claims without verifying them, as an instance of the calling class. Use it to decide how to verify (reading iss to pick a key set), then verify.

JWK

JWK.Algorithm

The supported algorithms: JWK.Algorithm.ES256 (EC, what signingKeys mints), .RS256 (RSA, common among upstream identity providers) and .EdDSA (OKP, deterministic nonces). The names also ship alone from @sdxc/jwt/algorithm:

import * as Algorithm from "@sdxc/jwt/algorithm";

Object.values(Algorithm); // ["ES256", "RS256", "EdDSA"]

JWK.generateKeyPair(alg: JWK.Algorithm): Promise<JWK.SerializedKeyPair>

Generates an extractable key pair as PEM strings, a UUID id and a millisecond timestamp, ready for JSON.stringify into a bucket or columns.

JWK.importKeyPair(value: JWK.SerializedKeyPair): Promise<JWK.KeyPair>

Imports a stored pair into CryptoKey objects, attaching the public half as a JWK with kid and use: "sig". The private key stays non-extractable. Each call imports two keys, so cache the result.

JWK.signingKeys(storage: KeyStorage): Promise<JWK.KeyPair[]>

Loads every key stored under signing:key:<id>, newest first, which is the order sign picks from. When none is unexpired, it mints and stores an ES256 key first.

JWK.importLocal(jwks: jose.JSONWebKeySet): Promise<JWK.KeyResolver>

A resolver over a key set already in hand.

JWK.importRemote(url: URL, options?: JWK.RemoteOptions): Promise<JWK.KeyResolver>

A resolver over a JWKS endpoint. It fetches on first use, holds the result, and refetches (at most once per cooldown) when a token names an unknown kid, which carries verifiers through a key rotation. Create it once per process.

JWK.toJSON(keys: JWK.KeyPair[]): JWK.PublishedKeySet

The JWKS document for /.well-known/jwks.json: each entry carries kty, kid, alg and only the public parameters (crv/x/y for EC, crv/x for OKP, e/n for RSA). Throws on any other key type.

Types

interface KeyPair {
	id: string; // the `kid`
	alg: Algorithm;
	public: jose.CryptoKey;
	private: jose.CryptoKey;
	created: Date;
	expired?: Date; // set once retired from signing
	jwk: jose.JWK; // public half, ready to publish
}

interface SerializedKeyPair {
	id: `${string}-${string}-${string}-${string}-${string}`;
	alg: Algorithm;
	publicKey: string; // SPKI PEM
	privateKey: string; // PKCS#8 PEM
	created: number; // epoch milliseconds
	expired?: Date;
}

interface SigningKey {
	id: string;
	alg: string;
	private: jose.CryptoKey;
}

interface VerificationKey {
	jwk: jose.JWK;
}

A KeyPair satisfies both SigningKey and VerificationKey. JWK.KeyResolver is jose.JWTVerifyGetKey; JWK.VerificationKeys is KeyResolver | VerificationKey[]; JWK.RemoteOptions is jose.RemoteJWKSetOptions. JWK.PublishedKeySet is { keys: PublishedKey[] }, where JWK.PublishedKey is a JWKS entry with a required kty.

KeyStorage

The store JWK.signingKeys reads and writes: File values under string keys, listed by prefix. Each method may be synchronous or return a promise.

interface KeyStorage {
	get(key: string): File | null | Promise<File | null>;
	list(options?: KeyStorageListOptions): KeyStorageListResult | Promise<KeyStorageListResult>;
	set(key: string, file: File): void | Promise<void>;
}

interface KeyStorageListOptions {
	cursor?: string;
	limit?: number;
	prefix?: string;
}

interface KeyStorageListResult {
	cursor?: string; // present only while more pages remain
	files: { key: string }[];
}

get answers null for an unknown key, list pages entries whose key starts with prefix, and set replaces what is stored. Keep every key ever written so every token it signed stays verifiable.

createR2KeyStorage(bucket: R2KeyBucket): KeyStorage

From @sdxc/jwt/r2. A KeyStorage over a Cloudflare R2 bucket, so every Worker issuing for one issuer shares the same keys. Files read back with the name, type and upload time they were stored with. R2KeyBucket is the get/list/put part of a bucket binding, which an R2Bucket satisfies; R2KeyObject is what its get answers.

Pattern: An Issuer In A Worker

Sign tokens and serve the matching JWKS from the same R2-backed key set.

import { JWK, JWT } from "@sdxc/jwt";
import { createR2KeyStorage } from "@sdxc/jwt/r2";
import { generateUUID } from "@sdxc/uuid/v4";
import { env } from "cloudflare:workers";

let ISSUER = "https://auth.example.com";

export default {
	async fetch(request: Request) {
		let url = new URL(request.url);
		let keys = await JWK.signingKeys(createR2KeyStorage(env.KEYS));

		if (url.pathname === "/.well-known/jwks.json") {
			return Response.json(JWK.toJSON(keys));
		}

		let token = new JWT({ iss: ISSUER, sub: "user-123", aud: "api", iat: "0s", exp: "15m" });
		token.id = generateUUID();

		return Response.json({ access_token: await token.sign(JWK.Algorithm.ES256, keys) });
	},
};

Pattern: Verifying An Upstream ID Token

Hold one resolver per provider, pin algorithms to what the provider's discovery document lists, and treat every failure as "not authenticated". Subclass getters throw on a missing or mistyped claim, so read them inside the same try.

import { JWK, JWT } from "@sdxc/jwt";

class ProviderIdToken extends JWT {
	get email() {
		return this.parser.string("email");
	}

	get emailVerified() {
		return this.parser.has("email_verified") && this.parser.boolean("email_verified");
	}
}

let KEYS = JWK.importRemote(new URL("https://accounts.example.com/.well-known/jwks.json"));

export async function verifyIdToken(raw: string, clientId: string) {
	try {
		let token = await ProviderIdToken.verify(raw, await KEYS, {
			issuer: "https://accounts.example.com",
			audience: clientId,
			algorithms: [JWK.Algorithm.RS256],
			clockTolerance: 60,
		});
		if (!token.emailVerified) return null;
		return { subject: token.subject, email: token.email };
	} catch {
		return null;
	}
}

Pattern: Several Issuers, One Verifier

decode reads iss before anything is trusted, to pick which key set verifies the token.

import { JWK, JWT } from "@sdxc/jwt";

let ISSUERS = new Map([
	[
		"https://auth.example.com",
		JWK.importRemote(new URL("https://auth.example.com/.well-known/jwks.json")),
	],
	["https://login.example.org", JWK.importRemote(new URL("https://login.example.org/keys"))],
]);

export async function verify(raw: string) {
	let issuer = JWT.decode(raw).issuer;
	let keys = issuer ? ISSUERS.get(issuer) : undefined;
	if (!issuer || !keys) return null;

	return JWT.verify(raw, await keys, { issuer, audience: "api" });
}

Pattern: Rotating Signing Keys

A rotation adds a key. Store a new pair under signing:key:<id>; signingKeys returns it first, so it signs from then on, while the older keys stay in the JWKS and keep verifying what they signed. Keep a retired key stored for at least as long as its tokens live.

import { JWK } from "@sdxc/jwt";
import { createR2KeyStorage } from "@sdxc/jwt/r2";
import { env } from "cloudflare:workers";

let storage = createR2KeyStorage(env.KEYS);
let pair = await JWK.generateKeyPair(JWK.Algorithm.ES256);

await storage.set(
	`signing:key:${pair.id}`,
	new File([JSON.stringify(pair)], "jwks.json", { type: "application/json" }),
);

let keys = await JWK.signingKeys(storage); // keys[0].id === pair.id

Deploy verifiers that resolve keys by kid before the issuer publishes a second key; a verifier built on JWK.importRemote picks the new key up the first time a token names it.

Pattern: Keys In A Database

When keys live in rows, generateKeyPair and importKeyPair are all you need; the imported pairs work with sign and JWK.toJSON exactly like signingKeys output.

import { JWK } from "@sdxc/jwt";

let serialized = await JWK.generateKeyPair(JWK.Algorithm.ES256);

await db.insert("signing_keys", {
	id: serialized.id,
	alg: serialized.alg,
	public_key: serialized.publicKey,
	private_key: serialized.privateKey,
	created_at: serialized.created,
});

let rows = await db.select("signing_keys", { orderBy: "created_at desc" });

let keys = await Promise.all(
	rows.map((row) =>
		JWK.importKeyPair({
			id: row.id as JWK.SerializedKeyPair["id"],
			alg: row.alg as JWK.Algorithm,
			publicKey: row.public_key,
			privateKey: row.private_key,
			created: row.created_at,
		}),
	),
);

Pattern: In-Memory Key Storage For Tests

A Map satisfies KeyStorage synchronously, so a test signs and verifies with no bucket.

import type { KeyStorage } from "@sdxc/jwt";

import { JWK, JWT } from "@sdxc/jwt";
import { expect, test } from "vitest";

function memoryStorage(): KeyStorage {
	let files = new Map<string, File>();
	return {
		get: (key) => files.get(key) ?? null,
		list: ({ prefix = "" } = {}) => ({
			files: [...files.keys()].filter((key) => key.startsWith(prefix)).map((key) => ({ key })),
		}),
		set: (key, file) => void files.set(key, file),
	};
}

test("a signed token verifies against the published set", async () => {
	let keys = await JWK.signingKeys(memoryStorage());
	let signed = await new JWT({ sub: "user-123", exp: "5m" }).sign(JWK.Algorithm.ES256, keys);

	let verified = await JWT.verify(signed, await JWK.importLocal(JWK.toJSON(keys)));

	expect(verified.subject).toBe("user-123");
});

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.