lacewing 1.0.1 → 1.1.0

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.
@@ -0,0 +1,654 @@
1
+ ---
2
+ title: Getting started
3
+ ---
4
+
5
+ # Getting started with Lacewing
6
+
7
+ Adopting Lacewing in a real service: keys, profiles, issuing, verifying,
8
+ transport, refresh tokens, revocation, and key distribution.
9
+
10
+ Every code sample below was executed against the library before publishing.
11
+
12
+ For the pitch and the API reference, see the [README](./README.md).
13
+
14
+ **Contents**
15
+
16
+ 1. [Before you start](#1-before-you-start)
17
+ 2. [A working example](#2-a-working-example)
18
+ 3. [Keys](#3-keys)
19
+ 4. [Profiles](#4-profiles)
20
+ 5. [Issuing tokens](#5-issuing-tokens)
21
+ 6. [Verifying tokens](#6-verifying-tokens)
22
+ 7. [Getting the token out of a request](#7-getting-the-token-out-of-a-request)
23
+ 8. [Access and refresh tokens](#8-access-and-refresh-tokens)
24
+ 9. [Revocation](#9-revocation)
25
+ 10. [Distributing keys in production](#10-distributing-keys-in-production)
26
+ 11. [Handling errors](#11-handling-errors)
27
+ 12. [Coming from `jsonwebtoken`](#12-coming-from-jsonwebtoken)
28
+ 13. [Adoption checklist](#13-adoption-checklist)
29
+
30
+ ---
31
+
32
+ ## 1. Before you start
33
+
34
+ Lacewing requires Node 24 or later and is ESM only. There is no CommonJS
35
+ build and no `require()` path.
36
+
37
+ ```bash
38
+ npm install lacewing
39
+ ```
40
+
41
+ Your `package.json` needs `"type": "module"`. If you are on CommonJS, that
42
+ migration comes first.
43
+
44
+ TypeScript is not required, but most of the design assumes it. `VerifiedJwt`
45
+ is a branded type that only `jwtVerify` produces, so handing an unverified
46
+ token to code expecting verified claims fails to compile. From plain
47
+ JavaScript you lose that check.
48
+
49
+ ---
50
+
51
+ ## 2. A working example
52
+
53
+ Copy this into `demo.mts` and run `node --experimental-strip-types demo.mts`:
54
+
55
+ ```ts
56
+ import { generateKeyPair, SignJWT, defineProfile, jwtVerify } from "lacewing";
57
+
58
+ const ISSUER = "https://auth.example.com";
59
+ const AUDIENCE = "https://api.example.com";
60
+
61
+ // 1. Keys. EdDSA by default.
62
+ const { publicKey, privateKey } = await generateKeyPair();
63
+
64
+ // 2. A profile: everything the verifier will demand. Define it once.
65
+ const profile = defineProfile({
66
+ typ: "at+jwt",
67
+ issuer: ISSUER,
68
+ audience: AUDIENCE,
69
+ algorithms: ["EdDSA"],
70
+ keys: publicKey,
71
+ maxTokenAge: "15m",
72
+ });
73
+
74
+ // 3. Issue.
75
+ const token = await new SignJWT("at+jwt")
76
+ .issuer(ISSUER)
77
+ .audience(AUDIENCE)
78
+ .subject("user-42")
79
+ .claim("scope", "read:documents")
80
+ .expiresIn("10m")
81
+ .sign(privateKey);
82
+
83
+ // 4. Verify. The only way to turn a string into trusted claims.
84
+ const { payload } = await jwtVerify(token, profile);
85
+ console.log(payload.sub, payload.scope); // user-42 read:documents
86
+ ```
87
+
88
+ ---
89
+
90
+ ## 3. Keys
91
+
92
+ ### Generating
93
+
94
+ ```ts
95
+ import { generateKeyPair, generateSecret } from "lacewing";
96
+
97
+ const { publicKey, privateKey } = await generateKeyPair(); // EdDSA
98
+ const { publicKey: p2, privateKey: k2 } = await generateKeyPair("ES256");
99
+ const secret = generateSecret("HS256"); // symmetric
100
+ ```
101
+
102
+ Supported algorithms: `EdDSA` (default), `ES256`/`ES384`/`ES512`,
103
+ `PS256`/`PS384`/`PS512`, `HS256`/`HS384`/`HS512`. The `RS*` family is not
104
+ registered at all. Using it means importing `lacewing/legacy/rsa` and calling
105
+ `enableLegacyRS256()` (or `enableLegacyRSA()` for the whole family), which is
106
+ the marker code review looks for. Prefer `PS256` or `EdDSA` where you control
107
+ both sides.
108
+
109
+ Use `EdDSA` unless you have a reason not to. Under `HS256` everyone who can
110
+ verify a token can also mint one, so your API servers can impersonate your
111
+ auth server.
112
+
113
+ A key is bound to one algorithm at import time. `LacewingKey<"ES256">`
114
+ cannot be passed where `LacewingKey<"HS256">` is expected, so algorithm
115
+ confusion is a compile error.
116
+
117
+ ### Generated private keys cannot be exported
118
+
119
+ `generateKeyPair()` produces a non-extractable private key by default, so it
120
+ cannot leave through `exportKeyPEM`:
121
+
122
+ ```ts
123
+ const { privateKey } = await generateKeyPair();
124
+ await exportKeyPEM(privateKey); // throws KeyExportFailed
125
+ ```
126
+
127
+ That suits a key generated at boot, but it means you cannot generate a key
128
+ and then save it. To persist one, ask for it:
129
+
130
+ ```ts
131
+ const { publicKey, privateKey } = await generateKeyPair("EdDSA", {
132
+ extractable: true,
133
+ });
134
+ const pem = await exportKeyPEM(privateKey); // now works; store it somewhere safe
135
+ ```
136
+
137
+ Public keys export either way.
138
+
139
+ ### Loading existing keys
140
+
141
+ ```ts
142
+ import { importKey } from "lacewing";
143
+
144
+ const signing = await importKey(process.env.PRIVATE_KEY_PEM!, "EdDSA");
145
+ const fromJwk = await importKey({ kty: "OKP", crv: "Ed25519", x: "..." }, "EdDSA");
146
+ const hmac = await importKey(process.env.SECRET!, "HS256");
147
+ ```
148
+
149
+ `importKey` runs an entropy check on HMAC secrets, so a password-shaped
150
+ secret fails at startup instead of producing weak signatures:
151
+
152
+ ```ts
153
+ await importKey("my-secret-password-123", "HS256"); // throws EntropyCheckFailed
154
+ ```
155
+
156
+ If that fires on a secret you already use, replace the secret.
157
+ `generateSecret("HS256")` produces one at the algorithm's minimum size.
158
+
159
+ ---
160
+
161
+ ## 4. Profiles
162
+
163
+ A profile is the complete set of demands a verifier makes, and the only
164
+ argument shape `jwtVerify` accepts. That is what makes `typ`, `issuer`,
165
+ `audience`, `algorithms` and `keys` impossible to omit.
166
+
167
+ ```ts
168
+ import { defineProfile } from "lacewing";
169
+
170
+ const profile = defineProfile({
171
+ typ: "at+jwt", // required: which kind of token
172
+ issuer: "https://auth.example.com",
173
+ audience: "https://api.example.com",
174
+ algorithms: ["EdDSA"], // the header never gets a vote
175
+ keys: publicKey,
176
+ maxTokenAge: "15m", // required: max age by iat, independent of exp
177
+
178
+ // optional
179
+ maxTokenLifetime: "1h", // refuse an implausible declared exp-iat span
180
+ maxClockSkew: "5s", // default 5s, capped at 120s
181
+ subject: "user-42", // pin the expected sub
182
+ revocation: store,
183
+ claimValidators: {
184
+ scope: (value) => {
185
+ if (typeof value !== "string" || !value.includes("read")) {
186
+ throw new Error("scope must include read");
187
+ }
188
+ },
189
+ },
190
+ });
191
+ ```
192
+
193
+ Define each profile once at module scope and export it. A profile is inert
194
+ configuration; building one per request wastes work and lets two call sites
195
+ disagree about what is acceptable.
196
+
197
+ ```ts
198
+ // auth/profiles.ts: the whole app imports from here
199
+ export const apiProfile = defineProfile({ /* ... */ });
200
+ ```
201
+
202
+ `maxTokenAge` and `exp` are separate controls and you want both. `exp` is
203
+ what the issuer claimed. `maxTokenAge` is what you accept regardless, so an
204
+ issuer that starts handing out 30-day access tokens does not widen your
205
+ window.
206
+
207
+ ---
208
+
209
+ ## 5. Issuing tokens
210
+
211
+ ```ts
212
+ import { SignJWT } from "lacewing";
213
+
214
+ const token = await new SignJWT("at+jwt") // typ is a constructor argument
215
+ .issuer("https://auth.example.com")
216
+ .audience("https://api.example.com")
217
+ .subject("user-42")
218
+ .claim("scope", "read:documents")
219
+ .claim("tenant", "acme")
220
+ .expiresIn("10m")
221
+ .sign(privateKey);
222
+ ```
223
+
224
+ `.sign()` refuses to run without `issuer`, `audience` and `expiresIn`. Each
225
+ is waivable through a method named to show up in code review:
226
+
227
+ ```ts
228
+ await new SignJWT("at+jwt")
229
+ .audience("...")
230
+ .expiresIn("5m")
231
+ .unsafeAllowMissingIssuer() // grep for `unsafeAllow` in CI
232
+ .sign(privateKey);
233
+ ```
234
+
235
+ Applied automatically, with no configuration:
236
+
237
+ - `jti`: a unique UUID on every token, so revocation always has a key
238
+ - `iat`: set at sign time
239
+ - Lifetime cap: `.expiresIn()` above 1h throws `MaxLifetimeExceeded`. Raise
240
+ it with `new SignJWT("at+jwt", { maxLifetime: "24h" })`
241
+ - Payload hygiene: the payload is scanned before signing and refuses
242
+ anything shaped like a password, card number, or PEM key
243
+
244
+ A JWS payload is base64url-encoded plaintext, readable by anyone holding the
245
+ token. That is what the last check is for:
246
+
247
+ ```ts
248
+ await new SignJWT("at+jwt")
249
+ .issuer(ISSUER).audience(AUDIENCE).expiresIn("5m")
250
+ .claim("password", "hunter2")
251
+ .sign(privateKey); // throws PayloadHygieneViolation
252
+ ```
253
+
254
+ For a false positive, allow that claim by name:
255
+
256
+ ```ts
257
+ .unsafeAllowClaim("password_changed_at")
258
+ ```
259
+
260
+ ---
261
+
262
+ ## 6. Verifying tokens
263
+
264
+ ```ts
265
+ import { jwtVerify } from "lacewing";
266
+
267
+ const verified = await jwtVerify(token, profile);
268
+ verified.payload.sub; // trusted
269
+ verified.header.alg; // trusted
270
+ ```
271
+
272
+ `jwtVerify` returns a fully checked `VerifiedJwt` or throws. There is no
273
+ partial result and no decoded-but-unverified object to trust by accident.
274
+ Checks run fail-fast in this order: structure, header, key resolution,
275
+ signature, claims, your custom validators, revocation.
276
+
277
+ Revocation runs last so unauthenticated input never reaches your revocation
278
+ store.
279
+
280
+ ### Reading a token you have not verified
281
+
282
+ Debugging and logging sometimes need the contents of an unverified token.
283
+ `unsafeDecode` returns one, branded `UntrustedJwt` and type-incompatible
284
+ with `VerifiedJwt`:
285
+
286
+ ```ts
287
+ import { unsafeDecode } from "lacewing";
288
+
289
+ const { header, payload } = unsafeDecode(token); // never trust these
290
+ console.log("token was issued by", payload.iss);
291
+ ```
292
+
293
+ ---
294
+
295
+ ## 7. Getting the token out of a request
296
+
297
+ Two transports. Both refuse ambiguous input rather than resolving it.
298
+
299
+ ```ts
300
+ import { parseBearer, readTokenCookie, setTokenCookie, buildTokenCookie } from "lacewing";
301
+
302
+ const token = parseBearer(request); // strict RFC 6750
303
+ const fromCookie = readTokenCookie(request); // __Host-token by default
304
+ ```
305
+
306
+ Do not put tokens in `localStorage` or `sessionStorage`. Both are readable
307
+ by any script on the page, so one XSS is total token theft. Use an
308
+ `HttpOnly` cookie in browsers and an in-memory bearer token between
309
+ services.
310
+
311
+ ```ts
312
+ setTokenCookie(response.headers, token);
313
+ // __Host-token=...; Path=/; HttpOnly; Secure; SameSite=Lax
314
+ ```
315
+
316
+ `HttpOnly`, `Secure` and `SameSite` are always emitted, there is no option
317
+ to remove them, and `SameSite=None` throws. The default name uses the
318
+ `__Host-` prefix, which binds the cookie to the exact origin and forbids
319
+ `Domain`.
320
+
321
+ To log someone out:
322
+
323
+ ```ts
324
+ import { clearTokenCookie } from "lacewing";
325
+ clearTokenCookie(response.headers);
326
+ ```
327
+
328
+ ### On Node frameworks
329
+
330
+ Both readers take WHATWG `Headers`, anything carrying them such as
331
+ `Request`, or the raw header string. Express, Fastify and Koa hand you a
332
+ plain object instead, so convert once at the edge:
333
+
334
+ ```ts
335
+ app.use((req, res, next) => {
336
+ const headers = new Headers(req.headers as Record<string, string>);
337
+ req.token = readTokenCookie(headers) ?? parseBearer(headers);
338
+ next();
339
+ });
340
+ ```
341
+
342
+ Passing the raw `req` throws a `TypeError` naming this fix. It does not read
343
+ as "no token".
344
+
345
+ The response side is a string, so it works anywhere:
346
+
347
+ ```ts
348
+ res.setHeader("Set-Cookie", buildTokenCookie(token)); // Express
349
+ reply.header("Set-Cookie", buildTokenCookie(token)); // Fastify
350
+ c.header("Set-Cookie", buildTokenCookie(token)); // Hono
351
+ ```
352
+
353
+ ---
354
+
355
+ ## 8. Access and refresh tokens
356
+
357
+ The most common token-confusion bug is a refresh token buying API access, or
358
+ an access token minting new sessions. Both profiles ship with the library:
359
+
360
+ ```ts
361
+ import {
362
+ accessTokenProfile, refreshTokenProfile,
363
+ newAccessToken, newRefreshToken,
364
+ } from "lacewing";
365
+
366
+ export const apiProfile = accessTokenProfile({
367
+ issuer: "https://auth.example.com",
368
+ audience: "https://api.example.com", // the API
369
+ algorithms: ["EdDSA"],
370
+ keys: { jwksUri: "https://auth.example.com/jwks" },
371
+ });
372
+
373
+ export const refreshProfile = refreshTokenProfile({
374
+ issuer: "https://auth.example.com",
375
+ audience: "https://auth.example.com/token", // the auth server, never the API
376
+ algorithms: ["EdDSA"],
377
+ keys: publicKey,
378
+ revocation: store, // long-lived means revocable
379
+ });
380
+ ```
381
+
382
+ | | access | refresh |
383
+ |---|---|---|
384
+ | `typ` | `at+jwt` | `rt+jwt` |
385
+ | audience | your API | your auth server's token endpoint |
386
+ | default max age | 10m | 30d |
387
+ | lifetime cap when signing | 1h | 90d |
388
+ | revocation | optional | required in practice |
389
+
390
+ Issuing:
391
+
392
+ ```ts
393
+ const access = await newAccessToken()
394
+ .issuer(ISSUER).audience(API).subject("user-42").expiresIn("10m").sign(privateKey);
395
+
396
+ const refresh = await newRefreshToken()
397
+ .issuer(ISSUER).audience(`${ISSUER}/token`).subject("user-42").expiresIn("30d").sign(privateKey);
398
+ ```
399
+
400
+ `typ` does the work. Even with identical keys, claims and audience, each
401
+ profile refuses the other's tokens:
402
+
403
+ ```ts
404
+ // throws JWTClaimValidationFailed: typ is rt+jwt, the profile demands at+jwt
405
+ await jwtVerify(refresh, apiProfile);
406
+ ```
407
+
408
+ ---
409
+
410
+ ## 9. Revocation
411
+
412
+ Every token gets a `jti`, so revocation always has a key. The in-memory
413
+ store suits a single process; implement the interface for anything else.
414
+
415
+ ```ts
416
+ import { MemoryRevocationStore, defineProfile } from "lacewing";
417
+
418
+ const store = new MemoryRevocationStore();
419
+
420
+ const profile = defineProfile({
421
+ typ: "at+jwt",
422
+ issuer: ISSUER,
423
+ audience: AUDIENCE,
424
+ algorithms: ["EdDSA"],
425
+ keys: publicKey,
426
+ maxTokenAge: "15m",
427
+ revocation: store,
428
+ });
429
+
430
+ // On logout. jti and exp both come off the verified payload.
431
+ const { payload } = await jwtVerify(token, profile);
432
+ store.revoke(payload.jti as string, payload.exp as number);
433
+
434
+ await jwtVerify(token, profile); // now throws JWTRevoked
435
+ ```
436
+
437
+ Entries are dropped once the token would have expired anyway, so the store
438
+ does not grow without bound.
439
+
440
+ For a deployment, back it with Redis or your database:
441
+
442
+ ```ts
443
+ import type { RevocationStore, TokenRevocationContext } from "lacewing";
444
+
445
+ class RedisRevocationStore implements RevocationStore {
446
+ constructor(private redis: Redis) {}
447
+
448
+ async isRevoked(ctx: TokenRevocationContext): Promise<boolean> {
449
+ if (ctx.jti === undefined) return false;
450
+ return (await this.redis.exists(`revoked:${ctx.jti}`)) === 1;
451
+ }
452
+ }
453
+ ```
454
+
455
+ The context also carries `sub`, `sid`, `exp` and `iat`, so "revoke every
456
+ session for this user" needs no interface change.
457
+
458
+ A store that throws fails closed: the token is rejected because you cannot
459
+ confirm it is not revoked. A Redis outage is therefore an auth outage. The
460
+ opposite trade exists and is named to be conspicuous:
461
+
462
+ ```ts
463
+ unsafeFailOpenOnRevocationError: true
464
+ ```
465
+
466
+ ---
467
+
468
+ ## 10. Distributing keys in production
469
+
470
+ Your API servers need the public key. In production that means a JWKS
471
+ endpoint rather than shipping a key file to every service:
472
+
473
+ ```ts
474
+ const profile = defineProfile({
475
+ typ: "at+jwt",
476
+ issuer: "https://auth.example.com",
477
+ audience: "https://api.example.com",
478
+ algorithms: ["EdDSA"],
479
+ keys: { jwksUri: "https://auth.example.com/.well-known/jwks.json" },
480
+ maxTokenAge: "15m",
481
+ });
482
+ ```
483
+
484
+ Caching, `Cache-Control` handling, a fetch cooldown, a size cap and a
485
+ timeout apply by default. HTTPS is mandatory, redirects are never followed,
486
+ and symmetric keys are refused: a JWKS endpoint is public, so an `oct` key
487
+ published there is one any reader could mint with.
488
+
489
+ The knobs and their defaults:
490
+
491
+ ```ts
492
+ keys: {
493
+ jwksUri: "https://auth.example.com/.well-known/jwks.json",
494
+ cacheTtlSeconds: 300,
495
+ cooldownSeconds: 30,
496
+ timeoutMs: 5000,
497
+ staleWhileErrorSeconds: 3600, // how long known-good keys serve during an outage
498
+ }
499
+ ```
500
+
501
+ `staleWhileErrorSeconds` is bounded deliberately. A key rotated out because
502
+ it was compromised must not stay trusted for as long as an attacker can keep
503
+ your JWKS endpoint unreachable. Set it to `0` to disable stale serving.
504
+
505
+ Publishing the JWKS from the signing side:
506
+
507
+ ```ts
508
+ import { exportKeyJWK } from "lacewing";
509
+
510
+ const jwks = { keys: [{ ...(await exportKeyJWK(publicKey)), kid: "2026-08" }] };
511
+ app.get("/.well-known/jwks.json", (_req, res) => res.json(jwks));
512
+ ```
513
+
514
+ ### Before you plan a rotation
515
+
516
+ `SignJWT` emits only `{ alg, typ }` in the header and cannot set a `kid`.
517
+ JWKS key selection matches on key type, curve and `kid`, so two keys of the
518
+ same algorithm in one JWKS are ambiguous for a Lacewing-signed token:
519
+
520
+ ```ts
521
+ // A JWKS holding two EdDSA keys, the state you are in during a rotation.
522
+ await jwtVerify(token, profile);
523
+ // throws JWKSNoMatchingKey: Multiple keys in the JWKS match this token -
524
+ // issue tokens with a kid
525
+ ```
526
+
527
+ Since you cannot issue with a `kid`, plan around it. In order of preference:
528
+
529
+ 1. Verify against an explicit key rather than a JWKS wherever you control
530
+ both ends. `keys: publicKey` has no ambiguity to resolve.
531
+ 2. Rotate across algorithms. Publish the outgoing `EdDSA` key and the
532
+ incoming `ES256` key together, allow both during the overlap, then drop
533
+ the old one. Tokens then select by `alg`:
534
+
535
+ ```ts
536
+ const profile = defineProfile({
537
+ typ: "at+jwt", issuer: ISSUER, audience: AUDIENCE,
538
+ algorithms: ["EdDSA", "ES256"], // both, only during the overlap
539
+ keys: { jwksUri: "https://auth.example.com/.well-known/jwks.json" },
540
+ maxTokenAge: "15m",
541
+ });
542
+ ```
543
+
544
+ 3. Hard cutover. Publish one key at a time and accept that tokens signed
545
+ with the old key fail for one token lifetime. At 10-minute access tokens
546
+ that window is small.
547
+
548
+ Verifying other issuers' tokens is unaffected. Lacewing reads and validates
549
+ `kid` normally, so a JWKS from Auth0, Okta or Cognito works as expected. The
550
+ constraint applies only to tokens Lacewing signs.
551
+
552
+ ---
553
+
554
+ ## 11. Handling errors
555
+
556
+ Every error extends `JWTError` and carries a machine-readable `code`.
557
+ Messages are generic and never echo token content, so they cannot become an
558
+ oracle or a log-injection vector.
559
+
560
+ ```ts
561
+ import { JWTError, JWTExpired, JWTRevoked } from "lacewing";
562
+
563
+ try {
564
+ const { payload } = await jwtVerify(token, profile);
565
+ return handle(payload);
566
+ } catch (error) {
567
+ if (error instanceof JWTExpired) return res.status(401).json({ error: "token_expired" });
568
+ if (error instanceof JWTRevoked) return res.status(401).json({ error: "revoked" });
569
+ if (error instanceof JWTError) {
570
+ logger.warn({ code: error.code }, "token rejected");
571
+ return res.status(401).json({ error: "invalid_token" });
572
+ }
573
+ throw error; // not a token problem; don't swallow it
574
+ }
575
+ ```
576
+
577
+ The codes you will branch on:
578
+
579
+ | Error | `code` | Means |
580
+ |---|---|---|
581
+ | `JWTInvalid` | `JWT_INVALID` | Malformed, or the signature doesn't check out |
582
+ | `JWTExpired` | `JWT_EXPIRED` | Past `exp`, or older than `maxTokenAge` |
583
+ | `JWTClaimValidationFailed` | `JWT_CLAIM_VALIDATION_FAILED` | A claim is present but wrong (`.claim` names it) |
584
+ | `MissingClaim` | `MISSING_CLAIM` | A required claim isn't there |
585
+ | `AlgorithmNotAllowed` | `ALGORITHM_NOT_ALLOWED` | `alg` isn't in your allowlist |
586
+ | `JWTRevoked` | `JWT_REVOKED` | The store says no |
587
+ | `RevocationCheckFailed` | `REVOCATION_CHECK_FAILED` | The store broke; failing closed |
588
+ | `JWKSFetchFailed` | `JWKS_FETCH_FAILED` | Couldn't fetch the JWKS |
589
+ | `JWKSNoMatchingKey` | `JWKS_NO_MATCHING_KEY` | No key matched, or several did |
590
+ | `EntropyCheckFailed` | `ENTROPY_CHECK_FAILED` | HMAC secret is too weak; a startup problem |
591
+ | `PayloadHygieneViolation` | `PAYLOAD_HYGIENE_VIOLATION` | You're about to sign a secret |
592
+
593
+ Do not return a distinct HTTP response per code to unauthenticated callers.
594
+ Use `401 invalid_token` for all of them and keep the detail in logs.
595
+
596
+ ---
597
+
598
+ ## 12. Coming from `jsonwebtoken`
599
+
600
+ The mapping is mechanical except for two habits.
601
+
602
+ | `jsonwebtoken` | Lacewing |
603
+ |---|---|
604
+ | `jwt.sign(payload, key, opts)` | `new SignJWT(typ).issuer().audience().expiresIn().sign(key)` |
605
+ | `jwt.verify(token, key, opts)` | `jwtVerify(token, profile)` |
606
+ | `jwt.decode(token)` | `unsafeDecode(token)`, branded untrusted |
607
+ | options per call site | one `defineProfile` shared by every call site |
608
+ | `algorithms: [...]` optional | mandatory, always |
609
+
610
+ Options move to the profile. Passing verification options at each call site
611
+ is how one endpoint ends up not checking `audience`. Define the profile once
612
+ and import it everywhere.
613
+
614
+ `typ` is mandatory. `jsonwebtoken` does not make you distinguish token
615
+ kinds, so most codebases have one sort of JWT doing several jobs. Lacewing
616
+ requires the kind to be named, and different kinds refuse each other.
617
+ Existing tokens without a `typ` have to be reissued; no profile accepts an
618
+ absent `typ`.
619
+
620
+ Migration order:
621
+
622
+ 1. Add Lacewing alongside your existing library; issue new tokens with both.
623
+ 2. Move verification to `jwtVerify` with a profile, behind a flag.
624
+ 3. Once all live tokens carry `typ`, drop the old path.
625
+
626
+ Step 3 waits one token lifetime.
627
+
628
+ ---
629
+
630
+ ## 13. Adoption checklist
631
+
632
+ - [ ] `"type": "module"` and Node 24+ in your deploy image
633
+ - [ ] Asymmetric keys (`EdDSA`) unless you deliberately chose otherwise
634
+ - [ ] Private key loaded from your secret manager, never from the repo
635
+ - [ ] One `defineProfile` per token kind, at module scope, exported
636
+ - [ ] `audience` is the specific service, not a wildcard
637
+ - [ ] `maxTokenAge` set to what you accept, not what the issuer claims
638
+ - [ ] Access tokens in minutes; refresh tokens revocable
639
+ - [ ] Browser tokens in `HttpOnly` cookies; grep for `localStorage` to be sure
640
+ - [ ] Node framework? `new Headers(req.headers)` at the edge
641
+ - [ ] A revocation store that survives a restart
642
+ - [ ] A decision on what a store outage should do; the default fails closed
643
+ - [ ] A rotation plan that accounts for the `kid` constraint in
644
+ [§10](#10-distributing-keys-in-production)
645
+ - [ ] `401 invalid_token` for every rejection; codes go to logs only
646
+ - [ ] CI greps for `unsafeAllow`, `unsafeDecode` and
647
+ `unsafeFailOpenOnRevocationError`
648
+
649
+ ---
650
+
651
+ `npm run demo` in the repository runs a live walkthrough where every
652
+ rejection is a real thrown error. For the full API, see the
653
+ [README](./README.md) and the generated
654
+ [TypeDoc](https://github.com/grMLEqomlkkU5Eeinz4brIrOVCUCkJuN/lacewing).