@sdxc/jwt 2026.9.4 → 2026.9.11

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 (2) hide show
  1. package/README.md +111 -229
  2. package/package.json +3 -3
package/README.md CHANGED
@@ -2,26 +2,25 @@
2
2
 
3
3
  JWT payload classes and the ES256, RS256 and EdDSA keys that sign them.
4
4
 
5
- ## Overview
5
+ ## Installation
6
6
 
7
- A decoded JWT is a bag of `unknown`. Reading claims off it directly scatters the same string literals and the same type checks over every call site, and nothing stops one of them from trusting `payload.email` on a token whose issuer never sends that claim.
8
-
9
- This package models a kind of token as a class instead. `JWT` carries the registered claims as typed accessors; an application subclasses it, narrows the accessors its tokens guarantee, and adds the ones it carries beyond the registered set — each one a single type-checked read against the claim set. Verifying through that subclass returns an instance of it, so the claims a handler reads and the claims the token class declares are the same list.
10
-
11
- `JWK` covers the other half: generating a key pair, serializing it so it survives in a bucket or a database row, importing it back into `CryptoKey` objects, publishing the public half as a JWKS, and turning someone else's JWKS — held locally or fetched — into the resolver a token is verified through.
7
+ ```bash
8
+ npm add @sdxc/jwt
9
+ ```
12
10
 
13
- An object model over JWT claim sets and the keys that sign them, then, sitting on top of a JOSE implementation that does the signing and verifying itself.
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.
14
12
 
15
13
  ## Usage
16
14
 
17
15
  ### Describing A Kind Of Token
18
16
 
17
+ Subclass `JWT` and give each claim the token guarantees a typed accessor.
18
+
19
19
  ```typescript
20
20
  import { JWT } from "@sdxc/jwt";
21
21
 
22
- export default class IdToken extends JWT {
23
- // Narrowed: this token always carries `sub`, so the accessor is `string`, not
24
- // `string | null`.
22
+ export class IdToken extends JWT {
23
+ // Narrowed: this token always carries `sub`, so the accessor is `string`.
25
24
  override get subject() {
26
25
  return this.parser.string("sub");
27
26
  }
@@ -29,10 +28,6 @@ export default class IdToken extends JWT {
29
28
  get email() {
30
29
  return this.parser.string("email");
31
30
  }
32
-
33
- get emailVerified() {
34
- return this.parser.boolean("email_verified");
35
- }
36
31
  }
37
32
  ```
38
33
 
@@ -59,10 +54,10 @@ let signed = await token.sign(JWK.Algorithm.ES256, keys);
59
54
  ```typescript
60
55
  import { JWK } from "@sdxc/jwt";
61
56
 
62
- // Hold this for the life of the isolate: it caches the fetched key set.
63
- let keys = JWK.importRemote(new URL(jwksUrl));
57
+ // Hold this for the life of the process: it caches the fetched key set.
58
+ let keys = await JWK.importRemote(new URL("https://auth.example.com/.well-known/jwks.json"));
64
59
 
65
- let idToken = await IdToken.verify(rawToken, await keys, {
60
+ let idToken = await IdToken.verify(rawToken, keys, {
66
61
  issuer: "https://auth.example.com",
67
62
  audience: clientId,
68
63
  algorithms: [JWK.Algorithm.ES256],
@@ -73,57 +68,33 @@ idToken.subject; // "user-123"
73
68
  idToken.email; // "ada@example.com"
74
69
  ```
75
70
 
76
- ### Publishing The Key Set
77
-
78
- ```typescript
79
- import { JWK } from "@sdxc/jwt";
80
-
81
- let keys = await JWK.signingKeys(storage);
82
-
83
- return Response.json(JWK.toJSON(keys), {
84
- headers: { "Cache-Control": "public, max-age=3600" },
85
- });
86
- ```
87
-
88
71
  ## API
89
72
 
90
73
  ### `JWT`
91
74
 
92
- A JWT claim set with the registered claims exposed as accessors. Meant to be subclassed; usable directly when a token needs no accessors of its own.
75
+ 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.
93
76
 
94
77
  #### `new JWT(payload?: JWT.PayloadInput)`
95
78
 
96
- Wraps a claim set.
97
-
98
- **Parameters:**
99
-
100
- - `payload`: The claims, defaulting to `{}` so a token can be built up through the setters
101
-
102
- `exp`, `iat`, and `nbf` accept a length of time 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 the seconds since the epoch and is used as given. Either way `payload` holds the resolved seconds, so the rest of the class, signing, and verification all see one form.
103
-
104
- ```typescript
105
- new JWT({ sub: "user-123", iat: "0s", exp: "1h" });
106
- new JWT({ sub: "user-123", exp: Math.floor(Date.now() / 1000) + 3600 }); // the same token
107
- ```
79
+ Wraps a claim set, defaulting to `{}` so a token can be built up through the setters.
108
80
 
109
- Instances are proxied: a claim with no accessor is still readable by name and returns `null` when absent, and assigning an unknown property writes it into the payload so it is carried into the signature.
81
+ `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.
110
82
 
111
- **Example:**
83
+ 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.
112
84
 
113
85
  ```typescript
114
- let jwt = new JWT({ sub: "user-123", scope: "read" });
115
- jwt.subject; // "user-123"
86
+ let jwt = new JWT({ sub: "user-123", scope: "read", exp: "1h" });
116
87
  jwt.scope; // "read" — no accessor declared, read through the payload
117
88
  jwt.missing; // null
118
89
  ```
119
90
 
120
91
  #### `jwt.payload`
121
92
 
122
- The raw claim set, readable whole. This is the same object the accessors read through, so a caller that wants the claim bag and a subclass that wants typed accessors can use the same instance.
93
+ The raw claim set, readable whole, and the same object the accessors read through.
123
94
 
124
95
  #### `jwt.parser`
125
96
 
126
- Protected. Type-checked reads over `payload`, for the accessors a subclass defines: `string`, `number`, `boolean`, `object`, and `has`. Each read throws when the claim is missing or holds the wrong type — see [Errors](#errors).
97
+ 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.
127
98
 
128
99
  #### Registered Claim Accessors
129
100
 
@@ -140,97 +111,43 @@ Protected. Type-checked reads over `payload`, for the accessors a subclass defin
140
111
  | `notBefore` | `nbf` | `Date \| null` | yes |
141
112
  | `subject` | `sub` | `string \| null` | yes |
142
113
 
143
- Every accessor answers `null` for a claim that is absent, so reading one never throws on a token that simply does not carry it. The setters take `null` to drop the claim, and convert `Date` values to the epoch seconds the RFC defines.
144
-
145
- `expiresIn` counts the seconds left before `exp` and goes negative once it has passed, and `expired` follows from it. Both read `exp` in the seconds RFC 7519 defines, which is what `JWT.verify` enforces expiry against.
114
+ 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.
146
115
 
147
116
  #### `jwt.sign(algorithm: JWK.Algorithm, jwks: JWK.SigningKey[]): Promise<string>`
148
117
 
149
- Signs this token with the first key in the set matching the algorithm, writing that key's `kid` into the header.
150
-
151
- **Returns:**
152
-
153
- - The compact-serialized token
154
-
155
- Throws when no key in the set was generated for that algorithm.
118
+ 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.
156
119
 
157
120
  #### `jwt.toJSON(): Record<string, unknown>`
158
121
 
159
- The claims a subclass exposes, as a plain object. Reads the getters declared directly on the instance's own prototype, so the result is the subclass's view of the token: the claims it models.
160
-
161
- #### `JWT.sign(jwt: JWT, algorithm: JWK.Algorithm, jwks: JWK.SigningKey[]): Promise<string>`
162
-
163
- The static form of `jwt.sign`, for signing a token you were handed.
122
+ The claims a subclass exposes, as a plain object, read from the getters declared on the instance's own prototype.
164
123
 
165
124
  #### `JWT.verify(token: string, jwks: JWK.VerificationKeys, options?: JWT.VerifyOptions): Promise<JWT>`
166
125
 
167
- Verifies a token and returns it as an instance of the class the method was called on.
126
+ 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.
168
127
 
169
- **Parameters:**
170
-
171
- - `token`: The compact-serialized token
172
- - `jwks`: The keys themselves, or a resolver from `JWK.importLocal` / `JWK.importRemote`
173
- - `options`: `issuer`, `audience`, `algorithms`, `clockTolerance`, and the rest of `JWT.VerifyOptions`
174
-
175
- **Returns:**
176
-
177
- - The verified token, typed as the class this was called on
178
-
179
- Signature, `exp`, `nbf`, and whichever of `issuer` and `audience` are given are all checked, and the first failure throws — so reaching the return value means the claims are trustworthy.
180
-
181
- The key is chosen per token, from the header the token carries: the key whose `kid` the header names is the one used, narrowed further by key type, curve, algorithm and intended use. A set that offers exactly one key for what the token asks for verifies it; a set that offers none, or several the token gives no way to choose between, is an error. Deciding per token is what lets an issuer publish several keys at once — during a rotation the retired key stays published and the tokens it signed keep verifying, while new tokens name the new key and get it.
182
-
183
- Pass `algorithms`, listing the ones the caller expects. It is the pin that keeps a token naming one algorithm from being answered with a key published for another, once a set carries keys for more than one.
184
-
185
- **Example:**
186
-
187
- ```typescript
188
- let idToken = await IdToken.verify(raw, keys, {
189
- issuer,
190
- audience: clientId,
191
- algorithms: [JWK.Algorithm.ES256],
192
- });
193
- ```
128
+ 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.
194
129
 
195
130
  #### `JWT.decode(token: string): JWT`
196
131
 
197
- Reads a token's claims without checking anything, as an instance of the class this was called on.
132
+ 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.
198
133
 
199
- What comes back is the token's own account of itself, read without any check. Use it to decide how to verify a token — reading `iss` to pick a JWKS, for instance — and let `verify` be what every decision about the request rests on.
200
-
201
- #### `JWT.Payload`
202
-
203
- ```typescript
204
- type Payload = jose.JWTPayload;
205
- ```
134
+ #### Types
206
135
 
207
- #### `JWT.VerifyOptions`
208
-
209
- ```typescript
210
- type VerifyOptions = jose.JWTVerifyOptions;
211
- ```
136
+ `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`.
212
137
 
213
138
  ### `JWK`
214
139
 
215
140
  #### `JWK.Algorithm`
216
141
 
217
- The supported signature algorithms.
218
-
219
- ```typescript
220
- JWK.Algorithm.ES256; // "ES256"
221
- JWK.Algorithm.RS256; // "RS256"
222
- JWK.Algorithm.EdDSA; // "EdDSA"
223
- ```
224
-
225
- | Algorithm | `kty` | Reach for it when |
226
- | --------- | ----- | --------------------------------------------------------- |
227
- | `ES256` | `EC` | Signing here; it is what a bootstrapped key is minted for |
228
- | `RS256` | `RSA` | Verifying an upstream identity provider's tokens |
229
- | `EdDSA` | `OKP` | Signing, where a deterministic nonce is wanted |
142
+ The supported signature algorithms, as `JWK.Algorithm.ES256`, `.RS256`, and `.EdDSA`.
230
143
 
231
- `RS256` earns its place on the verifying side: an upstream identity provider signs with what it chooses, and that is commonly `RS256`. `EdDSA` derives each signature's nonce from the key and the message rather than drawing a fresh one, which puts the nonce reuse that recovers an ECDSA private key out of reach.
144
+ | Algorithm | `kty` | Reach for it when |
145
+ | --------- | ----- | ---------------------------------------------------- |
146
+ | `ES256` | `EC` | Signing; it is what a bootstrapped key is minted for |
147
+ | `RS256` | `RSA` | Verifying an upstream identity provider's tokens |
148
+ | `EdDSA` | `OKP` | Signing, where a deterministic nonce is wanted |
232
149
 
233
- The names are also importable on their own, for a caller that needs to read or list them without the key machinery behind them:
150
+ 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:
234
151
 
235
152
  ```typescript
236
153
  import * as Algorithm from "@sdxc/jwt/algorithm";
@@ -238,85 +155,42 @@ import * as Algorithm from "@sdxc/jwt/algorithm";
238
155
  Object.values(Algorithm); // ["ES256", "RS256", "EdDSA"]
239
156
  ```
240
157
 
241
- ```typescript
242
- import { ES256 } from "@sdxc/jwt/algorithm";
243
- ```
244
-
245
- That entry point holds the three constants and nothing else, so importing it costs nothing beyond the names.
246
-
247
- All three coexist in one key set. Verification picks a key by the `kid` and the algorithm a token names, so a set publishing several resolves the same way a set of one does.
248
-
249
158
  #### `JWK.generateKeyPair(alg: JWK.Algorithm): Promise<JWK.SerializedKeyPair>`
250
159
 
251
- Generates a new key pair in serialized form: PEM strings and a millisecond timestamp, so the whole pair survives `JSON.stringify` into a bucket or a text column.
252
-
253
- Keys are generated extractable on purpose. A pair that cannot be exported cannot be stored, and a signing key that only exists in one isolate signs tokens no other isolate can verify.
160
+ 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.
254
161
 
255
162
  #### `JWK.importKeyPair(value: JWK.SerializedKeyPair): Promise<JWK.KeyPair>`
256
163
 
257
- Imports a stored pair back into usable `CryptoKey` objects, and attaches the public half as a JWK stamped with `kid` and `use: "sig"`.
258
-
259
- Stamping happens here rather than at publish time, so the identifier a token header carries and the identifier the JWKS advertises cannot drift apart.
260
-
261
- **Example:**
262
-
263
- ```typescript
264
- let pair = await JWK.importKeyPair({
265
- id: record.id,
266
- alg: JWK.Algorithm.ES256,
267
- publicKey: record.public_key,
268
- privateKey: record.private_key,
269
- created: new Date(record.created_at).getTime(),
270
- });
271
- ```
164
+ 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.
272
165
 
273
166
  #### `JWK.signingKeys(storage: KeyStorage): Promise<JWK.KeyPair[]>`
274
167
 
275
- Loads every signing key out of storage, generating one on first use. Newest first, which is the order `sign` relies on to pick what to sign with and the order `toJSON` publishes them in. A set holding several is the normal state during a rotation: the newest signs, the older ones stay published so the tokens they signed keep verifying. A new ES256 key is minted when nothing usable is stored at all.
276
-
277
- Generation time is the whole ordering, so a store holding keys for several algorithms comes back as one sequence and `sign` takes the newest key generated for the algorithm it was asked for.
278
-
279
- Point this at the bucket the issuer already keeps its keys in. Against an empty one it bootstraps a key, and tokens signed with it verify once every relying party has refreshed its copy of the published set.
168
+ 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.
280
169
 
281
170
  #### `JWK.importLocal(jwks: jose.JSONWebKeySet): Promise<JWK.KeyResolver>`
282
171
 
283
- Turns a key set that is already in hand into a resolver `JWT.verify` can use.
172
+ Turns a key set that is already in hand into a resolver `JWT.verify` accepts.
284
173
 
285
174
  #### `JWK.importRemote(url: URL, options?: JWK.RemoteOptions): Promise<JWK.KeyResolver>`
286
175
 
287
- Points a resolver at a JWKS endpoint. The document is fetched when a token first needs it, then held — so hold the resolver for the life of the isolate, and every verification shares one fetched key set.
288
-
289
- The held copy is fetched again, at most once per cooldown window, when a token names a `kid` it does not carry. That is what carries a verifier across a rotation between deploys: the first token signed by a newly published key is what pulls that key in.
290
-
291
- Both resolve per token rather than up front, because which key a token needs is what the token itself says. `JWT.verify` describes the selection that follows.
176
+ 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.
292
177
 
293
178
  #### `JWK.toJSON(keys: JWK.KeyPair[]): jose.JSONWebKeySet`
294
179
 
295
- Renders key pairs as the JSON a `/.well-known/jwks.json` endpoint serves. Every entry is built out of the parameters its key type publishes, drawn only from the public half, alongside the `kid` and `alg` a relying party selects on:
296
-
297
- | `kty` | Published entry |
298
- | ----- | ------------------------------------ |
299
- | `EC` | `crv`, `x`, `y`, `kty`, `kid`, `alg` |
300
- | `OKP` | `crv`, `x`, `kty`, `kid`, `alg` |
301
- | `RSA` | `e`, `n`, `kty`, `kid`, `alg` |
302
-
303
- The shape of the output is what guarantees a private key cannot reach the endpoint through a field nobody thought about: `d` on any of them, and `p`, `q`, `dp`, `dq` and `qi` on an RSA key besides. A key type absent from that table is one whose private parameters have never been enumerated, so it **throws** rather than publishing an entry nobody has vetted — and it throws for the document as a whole, so the rest of the set does not go out alongside an entry a relying party cannot use.
304
-
305
- `alg` is published because a set carrying more than one algorithm gives a relying party nothing else to select on before it reaches the signature.
180
+ 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.
306
181
 
307
182
  #### Types
308
183
 
309
184
  ```typescript
310
185
  interface KeyPair {
311
- id: string;
186
+ id: string; // published as the `kid`
312
187
  alg: Algorithm;
313
188
  public: jose.CryptoKey;
314
189
  private: jose.CryptoKey;
315
190
  created: Date;
316
- expired?: Date;
317
- jwk: jose.JWK;
191
+ expired?: Date; // set once the pair is retired from signing
192
+ jwk: jose.JWK; // public half, ready to publish
318
193
  }
319
-
320
194
  interface SerializedKeyPair {
321
195
  id: `${string}-${string}-${string}-${string}-${string}`;
322
196
  alg: Algorithm;
@@ -325,27 +199,21 @@ interface SerializedKeyPair {
325
199
  created: number; // epoch milliseconds
326
200
  expired?: Date;
327
201
  }
328
-
329
202
  interface SigningKey {
330
203
  id: string;
331
204
  alg: string;
332
205
  private: jose.CryptoKey;
333
206
  }
334
-
335
207
  interface VerificationKey {
336
- jwk: jose.JWK; // the published key, carrying the `kid` a token names
337
- }
338
-
339
- type KeyResolver = jose.JWTVerifyGetKey; // answers with the key a token calls for
340
- type VerificationKeys = KeyResolver | VerificationKey[];
341
- type RemoteOptions = jose.RemoteJWKSetOptions;
208
+ jwk: jose.JWK;
209
+ } // carries the `kid` a token names
342
210
  ```
343
211
 
344
- `KeyPair` satisfies both `SigningKey` and `VerificationKey`, so a full pair is accepted anywhere either is.
212
+ `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.
345
213
 
346
214
  ### `KeyStorage`
347
215
 
348
- The storage contract `JWK.signingKeys` is written against, and nothing more.
216
+ The storage contract `JWK.signingKeys` is written against: a key/value store of `File` objects that pages by prefix.
349
217
 
350
218
  ```typescript
351
219
  interface KeyStorage {
@@ -353,52 +221,42 @@ interface KeyStorage {
353
221
  list(options?: KeyStorageListOptions): KeyStorageListResult | Promise<KeyStorageListResult>;
354
222
  set(key: string, file: File): void | Promise<void>;
355
223
  }
356
- ```
357
-
358
- Three operations, each allowed to be synchronous or to return a promise — which is what lets a plain in-memory object satisfy it in a test while a bucket client satisfies it in production. Any object store that pages by prefix with an opaque cursor fits without an adapter.
359
-
360
- Reading, listing and writing are the whole contract: a key is written once and kept, so that every token it ever signed stays verifiable.
361
-
362
- ### Errors
363
-
364
- Claim reads through `this.parser` **throw** rather than returning a `Result`, which is a deliberate departure from the rest of this monorepo. The reads happen inside property getters, and a getter has no room for a `Result` without changing the type of every claim accessor in every token class and forcing each call site to unwrap a value that has already been through signature verification.
365
-
366
- | Error | Raised when |
367
- | ------------------ | ------------------------------------------------------- |
368
- | `ParserError` | Base class for both |
369
- | `MissingKeyError` | A claim an accessor requires is absent from the payload |
370
- | `InvalidTypeError` | A claim is present but holds the wrong JSON type |
371
224
 
372
- These classes are internal — a caller catches them by name (`ParserMissingKeyError`, `ParserInvalidTypeError`) or, more usually, does not catch them at all: a claim missing from a token that has already verified means the issuer sent something the token class does not model, which is closer to a bug than to a runtime condition worth branching on.
225
+ interface KeyStorageListOptions {
226
+ cursor?: string;
227
+ limit?: number;
228
+ prefix?: string;
229
+ }
230
+ interface KeyStorageListResult {
231
+ cursor?: string;
232
+ files: { key: string }[];
233
+ }
234
+ ```
373
235
 
374
- `boolean` is strict. The string `"true"` is a type error, not a `true` — silently coercing it would turn an identity provider's malformed `email_verified` into a trusted `true`.
236
+ 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.
375
237
 
376
238
  ## Rotation, And The Order To Roll It Out In
377
239
 
378
- A rotation adds a key. The issuer writes a new pair into storage, `signingKeys` returns it first, `sign` picks it because it is the newest, and the retired key stays in the published JWKS so that the tokens it already signed keep verifying until they expire. Keys are kept, which is what makes that possible.
240
+ 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:
379
241
 
380
- That rests on every verifier being able to pick a key by `kid`, which makes the rollout order a **deployment constraint as much as a code one**. Issuer and verifiers are deployed separately, so:
381
-
382
- 1. Deploy the verifiers first — everything that verifies tokens against the published set — and confirm they are live on a version that resolves a key per token.
242
+ 1. Deploy the verifiers first, and confirm they are live on a version that resolves a key per token.
383
243
  2. Then let the issuer publish more than one key.
384
244
 
385
- In that order every verifier can already tell two published keys apart on the day a second one appears. A verifier still on a version that resolves a single key up front has no way to choose between them, and it is every verification it does that stops, not a few.
386
-
387
245
  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.
388
246
 
389
247
  ## Pattern: Verifying An Upstream ID Token
390
248
 
391
- Resolve the key set once, at module scope or in a container singleton, and treat any failure — an unreachable JWKS, a bad signature, a wrong issuer — as the same "not authenticated" outcome.
392
-
393
- Pin `algorithms` to what the provider actually signs with, which is usually `RS256`. It is the provider's choice, not yours, so read its discovery document rather than assuming.
249
+ 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.
394
250
 
395
251
  ```typescript
396
- let verificationKey = JWK.importRemote(new URL(jwksUrl));
252
+ import { JWK, JWT } from "@sdxc/jwt";
253
+
254
+ let keys = JWK.importRemote(new URL("https://accounts.example.com/.well-known/jwks.json"));
397
255
 
398
- export async function verify(raw: string) {
256
+ export async function verifyIdToken(raw: string, clientId: string) {
399
257
  try {
400
- return await IdToken.verify(raw, await verificationKey, {
401
- issuer: "https://auth.example.com",
258
+ return await JWT.verify(raw, await keys, {
259
+ issuer: "https://accounts.example.com",
402
260
  audience: clientId,
403
261
  algorithms: [JWK.Algorithm.RS256],
404
262
  clockTolerance: 60,
@@ -409,42 +267,66 @@ export async function verify(raw: string) {
409
267
  }
410
268
  ```
411
269
 
412
- Wrap the accessor reads in the same `try` when the caller reads claims the issuer treats as optional: those throw from the getter itself.
270
+ A subclass accessor for a claim the provider treats as optional throws from the getter, so keep those reads inside the same `try`.
413
271
 
414
272
  ## Pattern: Keys In A Database Instead Of A Bucket
415
273
 
416
- `signingKeys` is a convenience over one particular storage shape. When keys live in rows, drive `generateKeyPair`/`importKeyPair` directly — they are the whole contract.
274
+ `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.
417
275
 
418
276
  ```typescript
277
+ import { JWK } from "@sdxc/jwt";
278
+
279
+ // Minting: a serialized pair is PEM strings and a timestamp, so it stores as plain columns.
419
280
  let serialized = await JWK.generateKeyPair(JWK.Algorithm.ES256);
420
281
 
421
- await db.create(table, {
282
+ await db.insert("signing_keys", {
422
283
  id: serialized.id,
284
+ alg: serialized.alg,
423
285
  public_key: serialized.publicKey,
424
286
  private_key: serialized.privateKey,
425
287
  created_at: new Date(serialized.created).toISOString(),
426
288
  });
427
289
 
428
- let pair = await JWK.importKeyPair(serialized);
290
+ // Signing and publishing: import every stored row back, newest first.
291
+ let rows = await db.select("signing_keys", { orderBy: "created_at desc" });
292
+
293
+ let keys = await Promise.all(
294
+ rows.map((row) =>
295
+ JWK.importKeyPair({
296
+ id: row.id as JWK.SerializedKeyPair["id"], // a UUID, as `generateKeyPair` wrote it
297
+ alg: row.alg,
298
+ publicKey: row.public_key,
299
+ privateKey: row.private_key,
300
+ created: new Date(row.created_at).getTime(),
301
+ }),
302
+ ),
303
+ );
304
+ ```
305
+
306
+ Each import loads two keys, so cache the resulting pairs for the life of a request at minimum.
307
+
308
+ ## Versioning
309
+
310
+ Releases are dated rather than semantic. A version is the UTC date it was published, written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one release goes out per day.
311
+
312
+ Those numbers say when, not what: a later date means a later release and carries no compatibility promise. Any release may change or remove an export.
313
+
314
+ Depend on one exact date, and move it when you are ready to take the change:
315
+
316
+ ```json
317
+ {
318
+ "dependencies": {
319
+ "@sdxc/jwt": "2026.9.4"
320
+ }
321
+ }
429
322
  ```
430
323
 
431
- Importing is not free — cache the resulting `KeyPair[]` for the life of a request at minimum, and key that cache per tenant if one isolate serves several.
324
+ A caret or tilde range reads the date as major, minor and patch, so it accepts every later release in the same year. An exact version keeps the upgrade yours to schedule.
432
325
 
433
- ## Related Packages
326
+ ## License
434
327
 
435
- - [`@sdxc/crypto`](/packages/crypto) - WebCrypto primitives for the secrets that are not tokens: password hashing, HMAC, sealed storage
436
- - [`@sdxc/oidc-provider`](/packages/oidc-provider) - OIDC/OAuth2 provider engine, whose token value objects are built on `JWT`
328
+ MIT
437
329
 
438
- ## Tips
330
+ ## Author
439
331
 
440
- 1. **Subclass per kind of token** - one class per token type, with an accessor for every claim it guarantees, is what keeps claim names out of handlers.
441
- 2. **Narrow the accessors you override** - a token that always carries `sub` should expose `string`, not `string | null`; that is the difference between one check and one at every call site.
442
- 3. **Resolve a remote JWKS once** - the resolver holds the fetched key set. Hold it in a container singleton or at module scope, so every request shares one.
443
- 4. **Route with `decode`, decide with `verify`** - what `decode` returns is the token's own account of itself, read without any check.
444
- 5. **Always pass `issuer` and `audience` to `verify`** - a valid signature from the right issuer for the wrong audience is still someone else's token.
445
- 6. **Give `clockTolerance` a value** - a small window (60 seconds is typical) absorbs drift between the issuer and the verifier without meaningfully extending a token's life.
446
- 7. **Use `expired` for display, not for access control** - it reports what the claim says, while `verify` is what rejects a token on it.
447
- 8. **Roll a rotation out verifier-first** - see [the ordering above](#rotation-and-the-order-to-roll-it-out-in); every verifier resolves a key per token before the issuer publishes a second one.
448
- 9. **Guard optional claims with `has`** - the typed reads throw, and `has` is the only cheap way to ask first.
449
- 10. **Point `signingKeys` at the bucket that already holds the keys** - against an empty one it bootstraps a fresh key, which verifies once every verifier has refreshed its copy of the published set.
450
- 11. **Pin `algorithms` to what the signer uses, not to what you issue** - `RS256` for an upstream identity provider, `ES256` or `EdDSA` for a token minted here.
332
+ [Sergio Xalambrí](https://sergiodxa.com)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/jwt",
3
- "version": "2026.9.4",
3
+ "version": "2026.9.11",
4
4
  "description": "JWT payload classes and the keys that sign them",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,10 +9,10 @@
9
9
  "./algorithm": "./dist/algorithm.js"
10
10
  },
11
11
  "dependencies": {
12
- "@sdxc/duration": "2026.9.4",
12
+ "@sdxc/duration": "2026.9.11",
13
13
  "jose": "^6.2.10"
14
14
  },
15
- "gitHead": "d56b596891af717b5f95e4d4d6bc953de7165e44",
15
+ "gitHead": "3aedbf2a8ed2251cecac480501941ee4c4614ec3",
16
16
  "publishConfig": {
17
17
  "access": "public"
18
18
  },