@sdxc/jwt 2026.10.12 → 2026.10.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +225 -95
  2. package/dist/jwk.js +2 -1
  3. package/package.json +4 -3
package/README.md CHANGED
@@ -2,30 +2,33 @@
2
2
 
3
3
  JWT payload classes and the ES256, RS256 and EdDSA keys that sign them.
4
4
 
5
+ Each kind of token is a class: the registered claims arrive as typed accessors, a subclass adds
6
+ its own, and signing, verifying and decoding all hand back an instance of that class. The `JWK`
7
+ namespace covers a key's whole life: generating, storing, importing, publishing as a JWKS and
8
+ resolving someone else's JWKS for verification.
9
+
5
10
  ## Installation
6
11
 
7
- ```bash
12
+ ```sh
8
13
  npm add @sdxc/jwt
9
14
  ```
10
15
 
11
- Signing and verification run on [`jose`](https://www.npmjs.com/package/jose), and the duration strings the time claims accept come from [`@sdxc/duration`](https://www.npmjs.com/package/@sdxc/duration). Both install with the package.
12
-
13
- Two entry points:
14
-
15
- - `@sdxc/jwt` — `JWT`, the `JWK` namespace, and the `KeyStorage` contract.
16
- - `@sdxc/jwt/r2` — `createR2KeyStorage`, the `KeyStorage` over a Cloudflare R2 bucket.
16
+ Signing and verification run on [`jose`](https://www.npmjs.com/package/jose), and duration strings
17
+ for the time claims come from [`@sdxc/duration`](https://www.npmjs.com/package/@sdxc/duration);
18
+ both install with the package. Three entry points: `@sdxc/jwt` (`JWT`, `JWK`, `KeyStorage`),
19
+ `@sdxc/jwt/algorithm` (the algorithm names alone) and `@sdxc/jwt/r2` (key storage over a
20
+ Cloudflare R2 bucket).
17
21
 
18
22
  ## Usage
19
23
 
20
- ### Describing A Kind Of Token
24
+ ### Describe a kind of token
21
25
 
22
- Subclass `JWT` and give each claim the token guarantees a typed accessor.
26
+ Subclass `JWT` and read each claim the token carries through `this.parser`, which type-checks it.
23
27
 
24
28
  ```typescript
25
29
  import { JWT } from "@sdxc/jwt";
26
30
 
27
31
  export class IdToken extends JWT {
28
- // Narrowed: this token always carries `sub`, so the accessor is `string`.
29
32
  override get subject() {
30
33
  return this.parser.string("sub");
31
34
  }
@@ -36,17 +39,25 @@ export class IdToken extends JWT {
36
39
  }
37
40
  ```
38
41
 
39
- ### Issuing A Token
42
+ ### Issue a token
43
+
44
+ `JWK.signingKeys` loads the keys from storage, minting an ES256 key the first time. Time claims
45
+ accept a duration measured from now.
40
46
 
41
47
  ```typescript
42
- import { JWK, JWT } from "@sdxc/jwt";
48
+ import { JWK } from "@sdxc/jwt";
49
+ import { createR2KeyStorage } from "@sdxc/jwt/r2";
50
+ import { env } from "cloudflare:workers";
43
51
 
44
- let keys = await JWK.signingKeys(storage);
52
+ import { IdToken } from "./id-token.js";
45
53
 
46
- let token = new JWT({
54
+ let keys = await JWK.signingKeys(createR2KeyStorage(env.KEYS));
55
+
56
+ let token = new IdToken({
47
57
  iss: "https://auth.example.com",
48
- sub: user.id,
49
- aud: client.id,
58
+ sub: "user-123",
59
+ aud: "client-abc",
60
+ email: "ada@example.com",
50
61
  iat: "0s",
51
62
  exp: "1h",
52
63
  });
@@ -54,19 +65,30 @@ let token = new JWT({
54
65
  let signed = await token.sign(JWK.Algorithm.ES256, keys);
55
66
  ```
56
67
 
57
- ### Verifying One
68
+ ### Publish the key set
69
+
70
+ ```typescript
71
+ let jwks = JWK.toJSON(keys);
72
+
73
+ return Response.json(jwks); // served at /.well-known/jwks.json
74
+ ```
75
+
76
+ ### Verify a token
77
+
78
+ Point a resolver at the issuer's JWKS once and keep it; `verify` answers an instance of the class
79
+ it is called on.
58
80
 
59
81
  ```typescript
60
82
  import { JWK } from "@sdxc/jwt";
61
83
 
62
- // Hold this for the life of the process: it caches the fetched key set.
84
+ import { IdToken } from "./id-token.js";
85
+
63
86
  let keys = await JWK.importRemote(new URL("https://auth.example.com/.well-known/jwks.json"));
64
87
 
65
88
  let idToken = await IdToken.verify(rawToken, keys, {
66
89
  issuer: "https://auth.example.com",
67
- audience: clientId,
90
+ audience: "client-abc",
68
91
  algorithms: [JWK.Algorithm.ES256],
69
- clockTolerance: 60,
70
92
  });
71
93
 
72
94
  idToken.subject; // "user-123"
@@ -77,31 +99,27 @@ idToken.email; // "ada@example.com"
77
99
 
78
100
  ### `JWT`
79
101
 
80
- A JWT claim set with the registered claims exposed as accessors. Subclass it to add accessors for the claims a kind of token carries, or use it directly when a token needs none of its own.
102
+ A claim set with the registered claims as accessors. Use it directly, or subclass it per kind of
103
+ token.
81
104
 
82
105
  #### `new JWT(payload?: JWT.PayloadInput)`
83
106
 
84
- Wraps a claim set, defaulting to `{}` so a token can be built up through the setters.
85
-
86
- `exp`, `iat`, and `nbf` accept a duration string as well as a number. Written as text, the claim is that long from the moment the token is built; written as a number, it is used as the seconds since the epoch. Either way `payload` holds the resolved seconds.
87
-
88
- Instances are proxied: a claim with no accessor still reads by name and answers `null` when absent, and assigning an unknown property writes it into the payload, so it is carried into the signature.
89
-
90
- ```typescript
91
- let jwt = new JWT({ sub: "user-123", scope: "read", exp: "1h" });
92
- jwt.scope; // "read" — no accessor declared, read through the payload
93
- jwt.missing; // null
94
- ```
107
+ Wraps a claim set, `{}` by default. `exp`, `iat` and `nbf` take seconds since the epoch or a
108
+ duration string (`"1h"`) measured from construction; `payload` holds the resolved seconds. A
109
+ claim with no accessor still reads by name (`null` when absent), and assigning an unknown property
110
+ writes it into the payload.
95
111
 
96
112
  #### `jwt.payload`
97
113
 
98
- The raw claim set, readable whole, and the same object the accessors read through.
114
+ The resolved claim set the accessors read and `sign` signs.
99
115
 
100
116
  #### `jwt.parser`
101
117
 
102
- Protected. Type-checked reads over `payload` for the accessors a subclass defines: `has`, `get`, `string`, `number`, `boolean`, and `object`. Every read but `has` throws on a malformed payload — a claim that is absent, or one holding another JSON type — rather than coercing, so `"true"` read as a `boolean` is an error. Ask with `has` first for an optional claim.
118
+ Protected, for subclass accessors: `has(key)`, `get(key)`, `string(key)`, `number(key)`,
119
+ `boolean(key)` and `object(key)` (a parser over a nested object). Every read but `has` throws when
120
+ the claim is absent or holds another JSON type, so guard an optional claim with `has`.
103
121
 
104
- #### Registered Claim Accessors
122
+ #### Registered claim accessors
105
123
 
106
124
  | Accessor | Claim | Type | Writable |
107
125
  | ---------------- | ----- | ---------------------------- | -------- |
@@ -116,43 +134,37 @@ Protected. Type-checked reads over `payload` for the accessors a subclass define
116
134
  | `notBefore` | `nbf` | `Date \| null` | yes |
117
135
  | `subject` | `sub` | `string \| null` | yes |
118
136
 
119
- Each accessor answers `null` for an absent claim. The setters take `null` to drop the claim, and convert `Date` values to epoch seconds. `expiresIn` counts the seconds left before `exp` and goes negative once it has passed, and `expired` follows from it; both report what the claim says, which suits display and cache decisions, while `JWT.verify` is what enforces expiry.
137
+ Absent claims read `null`, and setting `null` drops the claim. `expiresIn` is the seconds left
138
+ (negative once past), and `expired` is `true` once it reaches zero; `verify` is what enforces
139
+ expiry.
120
140
 
121
141
  #### `jwt.sign(algorithm: JWK.Algorithm, jwks: JWK.SigningKey[]): Promise<string>`
122
142
 
123
- Signs this token with the first key in the set generated for that algorithm, writing that key's `kid` into the header, and returns the compact-serialized token. Throws when the set holds no key for the algorithm. `JWT.sign(jwt, algorithm, jwks)` is the static form, for signing a token you were handed.
143
+ Signs with the first key generated for `algorithm`, writes its id as the header `kid`, and answers
144
+ the compact token. Throws when no key matches. `JWT.sign(jwt, algorithm, jwks)` is the static form.
124
145
 
125
146
  #### `jwt.toJSON(): Record<string, unknown>`
126
147
 
127
- The claims a subclass exposes, as a plain object, read from the getters declared on the instance's own prototype.
148
+ The getters declared on the instance's own class, as a plain object.
128
149
 
129
150
  #### `JWT.verify(token: string, jwks: JWK.VerificationKeys, options?: JWT.VerifyOptions): Promise<JWT>`
130
151
 
131
- Verifies a token and returns it as an instance of the class the method was called on. `jwks` is either the keys themselves as `VerificationKey[]` or a resolver from `JWK.importLocal` / `JWK.importRemote`; `options` carries `issuer`, `audience`, `algorithms`, `clockTolerance`, and the rest of `jose`'s verify options. Signature, `exp`, `nbf`, and whichever of `issuer` and `audience` are given are all checked, and the first failure throws.
132
-
133
- The key is chosen per token from the header it carries: the key its `kid` names, narrowed by key type, curve, algorithm, and intended use. Exactly one match verifies the token; none, or several the token gives no way to choose between, is an error. Choosing per token is what lets an issuer publish several keys at once. Pass `algorithms` with the ones you expect, so a token naming one algorithm is answered only with a key published for it.
152
+ Checks the signature, `exp`, `nbf` and any `issuer`/`audience` given, and answers an instance of
153
+ the calling class; any failure throws. The key is picked per token by its `kid`, key type and
154
+ algorithm, so a set can hold several keys. Pass `algorithms` to accept only the ones you expect.
134
155
 
135
156
  #### `JWT.decode(token: string): JWT`
136
157
 
137
- Reads a token's claims as the token presents them, as an instance of the class this was called on. Use it to decide how to verify a token — reading `iss` to pick a key set, for instance — and let `verify` be what every decision about the request rests on.
138
-
139
- #### Types
140
-
141
- `JWT.Payload` is `jose.JWTPayload`, with every time claim as epoch seconds. `JWT.PayloadInput` is the same claim set with `exp`, `iat`, and `nbf` also accepting a duration string. `JWT.VerifyOptions` is `jose.JWTVerifyOptions`.
158
+ Reads the claims without verifying them, as an instance of the calling class. Use it to decide how
159
+ to verify (reading `iss` to pick a key set), then `verify`.
142
160
 
143
161
  ### `JWK`
144
162
 
145
163
  #### `JWK.Algorithm`
146
164
 
147
- The supported signature algorithms, as `JWK.Algorithm.ES256`, `.RS256`, and `.EdDSA`.
148
-
149
- | Algorithm | `kty` | Reach for it when |
150
- | --------- | ----- | ---------------------------------------------------- |
151
- | `ES256` | `EC` | Signing; it is what a bootstrapped key is minted for |
152
- | `RS256` | `RSA` | Verifying an upstream identity provider's tokens |
153
- | `EdDSA` | `OKP` | Signing, where a deterministic nonce is wanted |
154
-
155
- All three coexist in one key set, since verification picks a key by the `kid` and algorithm a token names. The names are also importable on their own, for a caller that reads or lists them without the key machinery behind them:
165
+ The supported algorithms: `JWK.Algorithm.ES256` (`EC`, what `signingKeys` mints), `.RS256`
166
+ (`RSA`, common among upstream identity providers) and `.EdDSA` (`OKP`, deterministic nonces). The
167
+ names also ship alone from `@sdxc/jwt/algorithm`:
156
168
 
157
169
  ```typescript
158
170
  import * as Algorithm from "@sdxc/jwt/algorithm";
@@ -162,40 +174,49 @@ Object.values(Algorithm); // ["ES256", "RS256", "EdDSA"]
162
174
 
163
175
  #### `JWK.generateKeyPair(alg: JWK.Algorithm): Promise<JWK.SerializedKeyPair>`
164
176
 
165
- Generates a key pair in serialized form: PEM strings and a millisecond timestamp, so the pair survives `JSON.stringify` into a bucket or a text column. Keys are extractable, so a pair written by one process imports in every other one.
177
+ Generates an extractable key pair as PEM strings, a UUID `id` and a millisecond timestamp, ready
178
+ for `JSON.stringify` into a bucket or columns.
166
179
 
167
180
  #### `JWK.importKeyPair(value: JWK.SerializedKeyPair): Promise<JWK.KeyPair>`
168
181
 
169
- Imports a stored pair back into usable `CryptoKey` objects and attaches the public half as a JWK stamped with `kid` and `use: "sig"`, so the identifier a token header carries and the one the JWKS advertises stay the same. The public half imports extractable; the private half stays confined to the runtime. Keys held in database rows need nothing beyond this and `generateKeyPair`; cache the result, since each call imports two keys.
182
+ Imports a stored pair into `CryptoKey` objects, attaching the public half as a JWK with `kid` and
183
+ `use: "sig"`. The private key stays non-extractable. Each call imports two keys, so cache the
184
+ result.
170
185
 
171
186
  #### `JWK.signingKeys(storage: KeyStorage): Promise<JWK.KeyPair[]>`
172
187
 
173
- Loads every signing key out of storage, newest first, which is the order `sign` picks from and `toJSON` publishes in. A set holding several is the normal state during a rotation: the newest signs, and the older ones stay published so the tokens they signed keep verifying. When nothing usable is stored, an ES256 key is generated, written back, and returned.
188
+ Loads every key stored under `signing:key:<id>`, newest first, which is the order `sign` picks
189
+ from. When none is unexpired, it mints and stores an ES256 key first.
174
190
 
175
191
  #### `JWK.importLocal(jwks: jose.JSONWebKeySet): Promise<JWK.KeyResolver>`
176
192
 
177
- Turns a key set that is already in hand into a resolver `JWT.verify` accepts.
193
+ A resolver over a key set already in hand.
178
194
 
179
195
  #### `JWK.importRemote(url: URL, options?: JWK.RemoteOptions): Promise<JWK.KeyResolver>`
180
196
 
181
- Points a resolver at a JWKS endpoint. The document is fetched when a token first needs it and then held, so hold the resolver for the life of the process and every verification shares one fetched set. It is fetched again, at most once per cooldown window, when a token names a `kid` the held copy lacks — which is what carries a verifier across a rotation between deploys.
197
+ A resolver over a JWKS endpoint. It fetches on first use, holds the result, and refetches (at most
198
+ once per cooldown) when a token names an unknown `kid`, which carries verifiers through a key
199
+ rotation. Create it once per process.
182
200
 
183
- #### `JWK.toJSON(keys: JWK.KeyPair[]): jose.JSONWebKeySet`
201
+ #### `JWK.toJSON(keys: JWK.KeyPair[]): JWK.PublishedKeySet`
184
202
 
185
- Renders key pairs as the JSON a `/.well-known/jwks.json` endpoint serves. Each entry carries `kty`, `kid`, `alg`, and the public parameters its key type publishes — `crv`, `x`, `y` for `EC`, `crv`, `x` for `OKP`, `e`, `n` for `RSA` — which is what keeps private components out of the document. A key type outside that list throws, for the document as a whole.
203
+ The JWKS document for `/.well-known/jwks.json`: each entry carries `kty`, `kid`, `alg` and only the
204
+ public parameters (`crv`/`x`/`y` for `EC`, `crv`/`x` for `OKP`, `e`/`n` for `RSA`). Throws on any
205
+ other key type.
186
206
 
187
207
  #### Types
188
208
 
189
209
  ```typescript
190
210
  interface KeyPair {
191
- id: string; // published as the `kid`
211
+ id: string; // the `kid`
192
212
  alg: Algorithm;
193
213
  public: jose.CryptoKey;
194
214
  private: jose.CryptoKey;
195
215
  created: Date;
196
- expired?: Date; // set once the pair is retired from signing
216
+ expired?: Date; // set once retired from signing
197
217
  jwk: jose.JWK; // public half, ready to publish
198
218
  }
219
+
199
220
  interface SerializedKeyPair {
200
221
  id: `${string}-${string}-${string}-${string}-${string}`;
201
222
  alg: Algorithm;
@@ -204,21 +225,27 @@ interface SerializedKeyPair {
204
225
  created: number; // epoch milliseconds
205
226
  expired?: Date;
206
227
  }
228
+
207
229
  interface SigningKey {
208
230
  id: string;
209
231
  alg: string;
210
232
  private: jose.CryptoKey;
211
233
  }
234
+
212
235
  interface VerificationKey {
213
236
  jwk: jose.JWK;
214
- } // carries the `kid` a token names
237
+ }
215
238
  ```
216
239
 
217
- `JWK.KeyResolver` is `jose.JWTVerifyGetKey`, which answers with the key a given token calls for; `JWK.VerificationKeys` is `KeyResolver | VerificationKey[]`, what `JWT.verify` takes; `JWK.RemoteOptions` is `jose.RemoteJWKSetOptions`. `KeyPair` satisfies both `SigningKey` and `VerificationKey`, so a full pair is accepted anywhere either is.
240
+ A `KeyPair` satisfies both `SigningKey` and `VerificationKey`. `JWK.KeyResolver` is
241
+ `jose.JWTVerifyGetKey`; `JWK.VerificationKeys` is `KeyResolver | VerificationKey[]`;
242
+ `JWK.RemoteOptions` is `jose.RemoteJWKSetOptions`. `JWK.PublishedKeySet` is `{ keys:
243
+ PublishedKey[] }`, where `JWK.PublishedKey` is a JWKS entry with a required `kty`.
218
244
 
219
245
  ### `KeyStorage`
220
246
 
221
- The storage contract `JWK.signingKeys` is written against: a key/value store of `File` objects that pages by prefix.
247
+ The store `JWK.signingKeys` reads and writes: `File` values under string keys, listed by prefix.
248
+ Each method may be synchronous or return a promise.
222
249
 
223
250
  ```typescript
224
251
  interface KeyStorage {
@@ -232,72 +259,147 @@ interface KeyStorageListOptions {
232
259
  limit?: number;
233
260
  prefix?: string;
234
261
  }
262
+
235
263
  interface KeyStorageListResult {
236
- cursor?: string;
264
+ cursor?: string; // present only while more pages remain
237
265
  files: { key: string }[];
238
266
  }
239
267
  ```
240
268
 
241
- To implement it: `get` answers `null` for a key nothing is stored under; `list` returns the entries whose key starts with `prefix`, at most `limit` of them, resuming from `cursor`, and carries a `cursor` of its own only while further pages remain; `set` replaces whatever is stored under the key. Each method may be synchronous or return a promise, so a plain object satisfies the contract in a test while an object store satisfies it in production. `signingKeys` writes each pair as a JSON `SerializedKeyPair` under the key `signing:key:<id>` and lists with the prefix `signing:key` to find them again, so keep every key ever written and every token it signed stays verifiable.
269
+ `get` answers `null` for an unknown key, `list` pages entries whose key starts with `prefix`, and
270
+ `set` replaces what is stored. Keep every key ever written so every token it signed stays
271
+ verifiable.
242
272
 
243
273
  ### `createR2KeyStorage(bucket: R2KeyBucket): KeyStorage`
244
274
 
245
- From `@sdxc/jwt/r2`. Stores key files in a Cloudflare R2 bucket, the place a key outlives every isolate and every Worker issuing tokens for one issuer reads the same set.
275
+ From `@sdxc/jwt/r2`. A `KeyStorage` over a Cloudflare R2 bucket, so every Worker issuing for one
276
+ issuer shares the same keys. Files read back with the name, type and upload time they were stored
277
+ with. `R2KeyBucket` is the `get`/`list`/`put` part of a bucket binding, which an `R2Bucket`
278
+ satisfies; `R2KeyObject` is what its `get` answers.
279
+
280
+ ## Pattern: An Issuer In A Worker
281
+
282
+ Sign tokens and serve the matching JWKS from the same R2-backed key set.
246
283
 
247
284
  ```typescript
248
- import { JWK } from "@sdxc/jwt";
285
+ import { JWK, JWT } from "@sdxc/jwt";
249
286
  import { createR2KeyStorage } from "@sdxc/jwt/r2";
287
+ import { generateUUID } from "@sdxc/uuid/v4";
250
288
  import { env } from "cloudflare:workers";
251
289
 
252
- let keys = await JWK.signingKeys(createR2KeyStorage(env.KEYS));
253
- ```
254
-
255
- Each object is written with the file's type as its `Content-Type` and its name and type as custom metadata (`name`, `type`), so `get` hands back the `File` that was stored, its `lastModified` set to the upload time. An object written without that metadata reads back named after its key. `list` carries R2's cursor only while a page is truncated.
256
-
257
- `R2KeyBucket` is the part of a bucket binding the storage calls — `get`, `list` and `put` — declared structurally, so an `R2Bucket` satisfies it and importing the module needs no platform types. `R2KeyObject` is what its `get` answers.
290
+ let ISSUER = "https://auth.example.com";
258
291
 
259
- ## Rotation, And The Order To Roll It Out In
292
+ export default {
293
+ async fetch(request: Request) {
294
+ let url = new URL(request.url);
295
+ let keys = await JWK.signingKeys(createR2KeyStorage(env.KEYS));
260
296
 
261
- A rotation adds a key: the issuer writes a new pair into storage, `signingKeys` returns it first, `sign` picks it as the newest, and the retired key stays in the published JWKS so the tokens it signed keep verifying until they expire. That rests on every verifier resolving a key per token by `kid`, which makes the rollout order a deployment constraint:
297
+ if (url.pathname === "/.well-known/jwks.json") {
298
+ return Response.json(JWK.toJSON(keys));
299
+ }
262
300
 
263
- 1. Deploy the verifiers first, and confirm they are live on a version that resolves a key per token.
264
- 2. Then let the issuer publish more than one key.
301
+ let token = new JWT({ iss: ISSUER, sub: "user-123", aud: "api", iat: "0s", exp: "15m" });
302
+ token.id = generateUUID();
265
303
 
266
- A verifier picks a newly published key up when a token first names it, so keep a retired key published for at least as long as the tokens it signed can live.
304
+ return Response.json({ access_token: await token.sign(JWK.Algorithm.ES256, keys) });
305
+ },
306
+ };
307
+ ```
267
308
 
268
309
  ## Pattern: Verifying An Upstream ID Token
269
310
 
270
- Resolve the key set once and hold it, so every verification shares one fetched document. Pin `algorithms` to what the provider signs with — that is its choice, so read its discovery document rather than assuming — and treat every failure, from an unreachable JWKS to a wrong issuer, as the same "not authenticated" outcome.
311
+ Hold one resolver per provider, pin `algorithms` to what the provider's discovery document lists,
312
+ and treat every failure as "not authenticated". Subclass getters throw on a missing or mistyped
313
+ claim, so read them inside the same `try`.
271
314
 
272
315
  ```typescript
273
316
  import { JWK, JWT } from "@sdxc/jwt";
274
317
 
275
- let keys = JWK.importRemote(new URL("https://accounts.example.com/.well-known/jwks.json"));
318
+ class ProviderIdToken extends JWT {
319
+ get email() {
320
+ return this.parser.string("email");
321
+ }
322
+
323
+ get emailVerified() {
324
+ return this.parser.has("email_verified") && this.parser.boolean("email_verified");
325
+ }
326
+ }
327
+
328
+ let KEYS = JWK.importRemote(new URL("https://accounts.example.com/.well-known/jwks.json"));
276
329
 
277
330
  export async function verifyIdToken(raw: string, clientId: string) {
278
331
  try {
279
- return await JWT.verify(raw, await keys, {
332
+ let token = await ProviderIdToken.verify(raw, await KEYS, {
280
333
  issuer: "https://accounts.example.com",
281
334
  audience: clientId,
282
335
  algorithms: [JWK.Algorithm.RS256],
283
336
  clockTolerance: 60,
284
337
  });
338
+ if (!token.emailVerified) return null;
339
+ return { subject: token.subject, email: token.email };
285
340
  } catch {
286
341
  return null;
287
342
  }
288
343
  }
289
344
  ```
290
345
 
291
- A subclass accessor for a claim the provider treats as optional throws from the getter, so keep those reads inside the same `try`.
346
+ ## Pattern: Several Issuers, One Verifier
347
+
348
+ `decode` reads `iss` before anything is trusted, to pick which key set verifies the token.
349
+
350
+ ```typescript
351
+ import { JWK, JWT } from "@sdxc/jwt";
352
+
353
+ let ISSUERS = new Map([
354
+ [
355
+ "https://auth.example.com",
356
+ JWK.importRemote(new URL("https://auth.example.com/.well-known/jwks.json")),
357
+ ],
358
+ ["https://login.example.org", JWK.importRemote(new URL("https://login.example.org/keys"))],
359
+ ]);
360
+
361
+ export async function verify(raw: string) {
362
+ let issuer = JWT.decode(raw).issuer;
363
+ let keys = issuer ? ISSUERS.get(issuer) : undefined;
364
+ if (!issuer || !keys) return null;
365
+
366
+ return JWT.verify(raw, await keys, { issuer, audience: "api" });
367
+ }
368
+ ```
369
+
370
+ ## Pattern: Rotating Signing Keys
371
+
372
+ A rotation adds a key. Store a new pair under `signing:key:<id>`; `signingKeys` returns it first,
373
+ so it signs from then on, while the older keys stay in the JWKS and keep verifying what they signed.
374
+ Keep a retired key stored for at least as long as its tokens live.
375
+
376
+ ```typescript
377
+ import { JWK } from "@sdxc/jwt";
378
+ import { createR2KeyStorage } from "@sdxc/jwt/r2";
379
+ import { env } from "cloudflare:workers";
380
+
381
+ let storage = createR2KeyStorage(env.KEYS);
382
+ let pair = await JWK.generateKeyPair(JWK.Algorithm.ES256);
383
+
384
+ await storage.set(
385
+ `signing:key:${pair.id}`,
386
+ new File([JSON.stringify(pair)], "jwks.json", { type: "application/json" }),
387
+ );
388
+
389
+ let keys = await JWK.signingKeys(storage); // keys[0].id === pair.id
390
+ ```
391
+
392
+ Deploy verifiers that resolve keys by `kid` before the issuer publishes a second key; a verifier
393
+ built on `JWK.importRemote` picks the new key up the first time a token names it.
292
394
 
293
- ## Pattern: Keys In A Database Instead Of A Bucket
395
+ ## Pattern: Keys In A Database
294
396
 
295
- `JWK.signingKeys` is a convenience over one storage shape, the `KeyStorage` one. When keys live in rows instead, drive `generateKeyPair` and `importKeyPair` directly against the database client — those two are the whole contract, and what they hand back is the same `JWK.KeyPair[]` that `sign` and `JWK.toJSON` take.
397
+ When keys live in rows, `generateKeyPair` and `importKeyPair` are all you need; the imported
398
+ pairs work with `sign` and `JWK.toJSON` exactly like `signingKeys` output.
296
399
 
297
400
  ```typescript
298
401
  import { JWK } from "@sdxc/jwt";
299
402
 
300
- // Minting: a serialized pair is PEM strings and a timestamp, so it stores as plain columns.
301
403
  let serialized = await JWK.generateKeyPair(JWK.Algorithm.ES256);
302
404
 
303
405
  await db.insert("signing_keys", {
@@ -305,26 +407,54 @@ await db.insert("signing_keys", {
305
407
  alg: serialized.alg,
306
408
  public_key: serialized.publicKey,
307
409
  private_key: serialized.privateKey,
308
- created_at: new Date(serialized.created).toISOString(),
410
+ created_at: serialized.created,
309
411
  });
310
412
 
311
- // Signing and publishing: import every stored row back, newest first.
312
413
  let rows = await db.select("signing_keys", { orderBy: "created_at desc" });
313
414
 
314
415
  let keys = await Promise.all(
315
416
  rows.map((row) =>
316
417
  JWK.importKeyPair({
317
- id: row.id as JWK.SerializedKeyPair["id"], // a UUID, as `generateKeyPair` wrote it
318
- alg: row.alg,
418
+ id: row.id as JWK.SerializedKeyPair["id"],
419
+ alg: row.alg as JWK.Algorithm,
319
420
  publicKey: row.public_key,
320
421
  privateKey: row.private_key,
321
- created: new Date(row.created_at).getTime(),
422
+ created: row.created_at,
322
423
  }),
323
424
  ),
324
425
  );
325
426
  ```
326
427
 
327
- Each import loads two keys, so cache the resulting pairs for the life of a request at minimum.
428
+ ## Pattern: In-Memory Key Storage For Tests
429
+
430
+ A `Map` satisfies `KeyStorage` synchronously, so a test signs and verifies with no bucket.
431
+
432
+ ```typescript
433
+ import type { KeyStorage } from "@sdxc/jwt";
434
+
435
+ import { JWK, JWT } from "@sdxc/jwt";
436
+ import { expect, test } from "vitest";
437
+
438
+ function memoryStorage(): KeyStorage {
439
+ let files = new Map<string, File>();
440
+ return {
441
+ get: (key) => files.get(key) ?? null,
442
+ list: ({ prefix = "" } = {}) => ({
443
+ files: [...files.keys()].filter((key) => key.startsWith(prefix)).map((key) => ({ key })),
444
+ }),
445
+ set: (key, file) => void files.set(key, file),
446
+ };
447
+ }
448
+
449
+ test("a signed token verifies against the published set", async () => {
450
+ let keys = await JWK.signingKeys(memoryStorage());
451
+ let signed = await new JWT({ sub: "user-123", exp: "5m" }).sign(JWK.Algorithm.ES256, keys);
452
+
453
+ let verified = await JWT.verify(signed, await JWK.importLocal(JWK.toJSON(keys)));
454
+
455
+ expect(verified.subject).toBe("user-123");
456
+ });
457
+ ```
328
458
 
329
459
  ## Versioning
330
460
 
package/dist/jwk.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * @author [Sergio Xalambrí](https://sergiodxa.com)
10
10
  * @copyright Sergio Xalambrí 2026
11
11
  */
12
+ import { generateUUID } from "@sdxc/uuid/v4";
12
13
  import * as jose from "jose";
13
14
  import * as SignatureAlgorithm from "./algorithm.js";
14
15
  /**
@@ -62,7 +63,7 @@ export var JWK;
62
63
  async function generateKeyPair(alg) {
63
64
  let key = await jose.generateKeyPair(alg, { extractable: true });
64
65
  return {
65
- id: crypto.randomUUID(),
66
+ id: generateUUID(),
66
67
  publicKey: await jose.exportSPKI(key.publicKey),
67
68
  privateKey: await jose.exportPKCS8(key.privateKey),
68
69
  created: Date.now(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/jwt",
3
- "version": "2026.10.12",
3
+ "version": "2026.10.13",
4
4
  "description": "JWT payload classes and the keys that sign them",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,10 +10,11 @@
10
10
  "./r2": "./dist/r2-key-storage.js"
11
11
  },
12
12
  "dependencies": {
13
- "@sdxc/duration": "2026.10.6",
13
+ "@sdxc/duration": "2026.10.13",
14
+ "@sdxc/uuid": "2026.10.13",
14
15
  "jose": "^6.2.12"
15
16
  },
16
- "gitHead": "857ef87ddb0b2484e28b46431d0740fcc80e5f33",
17
+ "gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
17
18
  "publishConfig": {
18
19
  "access": "public"
19
20
  },