Adopting Lacewing in a real service: keys, profiles, issuing, verifying, transport, refresh tokens, revocation, and key distribution.
Every code sample below was executed against the library before publishing.
For the pitch and the API reference, see the README.
Contents
jsonwebtokenLacewing requires Node 24 or later and is ESM only. There is no CommonJS
build and no require() path.
npm install lacewing
Your package.json needs "type": "module". If you are on CommonJS, that
migration comes first.
TypeScript is not required, but most of the design assumes it. VerifiedJwt
is a branded type that only jwtVerify produces, so handing an unverified
token to code expecting verified claims fails to compile. From plain
JavaScript you lose that check.
Copy this into demo.mts and run node --experimental-strip-types demo.mts:
import { generateKeyPair, SignJWT, defineProfile, jwtVerify } from "lacewing";
const ISSUER = "https://auth.example.com";
const AUDIENCE = "https://api.example.com";
// 1. Keys. EdDSA by default.
const { publicKey, privateKey } = await generateKeyPair();
// 2. A profile: everything the verifier will demand. Define it once.
const profile = defineProfile({
typ: "at+jwt",
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["EdDSA"],
keys: publicKey,
maxTokenAge: "15m",
});
// 3. Issue.
const token = await new SignJWT("at+jwt")
.issuer(ISSUER)
.audience(AUDIENCE)
.subject("user-42")
.claim("scope", "read:documents")
.expiresIn("10m")
.sign(privateKey);
// 4. Verify. The only way to turn a string into trusted claims.
const { payload } = await jwtVerify(token, profile);
console.log(payload.sub, payload.scope); // user-42 read:documents
import { generateKeyPair, generateSecret } from "lacewing";
const { publicKey, privateKey } = await generateKeyPair(); // EdDSA
const { publicKey: p2, privateKey: k2 } = await generateKeyPair("ES256");
const secret = generateSecret("HS256"); // symmetric
Supported algorithms: EdDSA (default), ES256/ES384/ES512,
PS256/PS384/PS512, HS256/HS384/HS512. The RS* family is not in
that set, and "RS256" as a string is refused. Using it means importing
LEGACY_RS256 (or LEGACY_RSA for the whole family) from
lacewing/legacy/rsa and passing it to importKey and to the profile's
algorithms, which is the line code review looks for. It applies only where
you pass it. Prefer PS256 or EdDSA where you control both sides.
Use EdDSA unless you have a reason not to. Under HS256 everyone who can
verify a token can also mint one, so your API servers can impersonate your
auth server.
A key is bound to one algorithm at import time. LacewingKey<"ES256">
cannot be passed where LacewingKey<"HS256"> is expected, so algorithm
confusion is a compile error.
generateKeyPair() produces a non-extractable private key by default, so it
cannot leave through exportKeyPEM:
const { privateKey } = await generateKeyPair();
await exportKeyPEM(privateKey); // throws KEY_EXPORT_FAILED
That suits a key generated at boot, but it means you cannot generate a key and then save it. To persist one, ask for it:
const { publicKey, privateKey } = await generateKeyPair("EdDSA", {
extractable: true,
});
const pem = await exportKeyPEM(privateKey); // now works; store it somewhere safe
Public keys export either way.
import { importKey } from "lacewing";
const signing = await importKey(process.env.PRIVATE_KEY_PEM!, "EdDSA");
const fromJwk = await importKey({ kty: "OKP", crv: "Ed25519", x: "..." }, "EdDSA");
const hmac = await importKey(process.env.SECRET!, "HS256");
importKey runs an entropy check on HMAC secrets, so a password-shaped
secret fails at startup instead of producing weak signatures:
await importKey("my-secret-password-123", "HS256"); // throws ENTROPY_CHECK_FAILED
A string secret must be random bytes written as hex or base64(url), long enough to carry the algorithm's key size: for HS256 that is 64 hex or 43 base64 characters. Spaces and punctuation are refused, which also catches random secrets from generators that mix in symbols.
If that fires on a secret you already use, replace the secret.
generateSecret("HS256") produces one at the algorithm's minimum size.
A profile is the complete set of demands a verifier makes, and the only
argument shape jwtVerify accepts. That is what makes typ, issuer,
audience, algorithms and keys impossible to omit.
import { defineProfile } from "lacewing";
const profile = defineProfile({
typ: "at+jwt", // required: which kind of token
issuer: "https://auth.example.com",
audience: "https://api.example.com",
algorithms: ["EdDSA"], // the header never gets a vote
keys: publicKey,
maxTokenAge: "15m", // required: max age by iat, independent of exp
// optional
maxTokenLifetime: "1h", // refuse an implausible declared exp-iat span
maxClockSkew: "5s", // default 5s, capped at 120s
subject: "user-42", // pin the expected sub
revocation: store,
claimValidators: {
scope: (value) => {
if (typeof value !== "string" || !value.includes("read")) {
throw new Error("scope must include read");
}
},
},
});
Define each profile once at module scope and export it. A profile is inert configuration; building one per request wastes work and lets two call sites disagree about what is acceptable.
// auth/profiles.ts: the whole app imports from here
export const apiProfile = defineProfile({ /* ... */ });
maxTokenAge and exp are separate controls and you want both. exp is
what the issuer claimed. maxTokenAge is what you accept regardless, so an
issuer that starts handing out 30-day access tokens does not widen your
window.
import { SignJWT } from "lacewing";
const token = await new SignJWT("at+jwt") // typ is a constructor argument
.issuer("https://auth.example.com")
.audience("https://api.example.com")
.subject("user-42")
.claim("scope", "read:documents")
.claim("tenant", "acme")
.expiresIn("10m")
.sign(privateKey);
.sign() refuses to run without issuer, audience and expiresIn. Each
is waivable through a method named to show up in code review:
await new SignJWT("at+jwt")
.audience("...")
.expiresIn("5m")
.unsafeAllowMissingIssuer() // grep for `unsafeAllow` in CI
.sign(privateKey);
Applied automatically, with no configuration:
jti: a unique UUID on every token, so revocation always has a keyiat: set at sign time.expiresIn() above 1h throws MAX_LIFETIME_EXCEEDED. Raise
it with new SignJWT("at+jwt", { maxLifetime: "24h" })A JWS payload is base64url-encoded plaintext, readable by anyone holding the token. That is what the last check is for:
await new SignJWT("at+jwt")
.issuer(ISSUER).audience(AUDIENCE).expiresIn("5m")
.claim("password", "hunter2")
.sign(privateKey); // throws PAYLOAD_HYGIENE_VIOLATION
For a false positive, allow that claim by name:
.unsafeAllowClaim("password_changed_at")
import { jwtVerify } from "lacewing";
const verified = await jwtVerify(token, profile);
verified.payload.sub; // trusted
verified.header.alg; // trusted
jwtVerify returns a fully checked VerifiedJwt or throws. There is no
partial result and no decoded-but-unverified object to trust by accident.
Checks run fail-fast in this order: structure, header, key resolution,
signature, claims, your custom validators, revocation.
Revocation runs last so unauthenticated input never reaches your revocation store.
Debugging and logging sometimes need the contents of an unverified token.
unsafeDecode returns one, branded UntrustedJwt and type-incompatible
with VerifiedJwt:
import { unsafeDecode } from "lacewing";
const { header, payload } = unsafeDecode(token); // never trust these
console.log("token was issued by", payload.iss);
Two transports. Both refuse ambiguous input rather than resolving it.
import { parseBearer, readTokenCookie, setTokenCookie, buildTokenCookie } from "lacewing";
const token = parseBearer(request); // strict RFC 6750
const fromCookie = readTokenCookie(request); // __Host-token by default
Do not put tokens in localStorage or sessionStorage. Both are readable
by any script on the page, so one XSS is total token theft. Use an
HttpOnly cookie in browsers and an in-memory bearer token between
services.
setTokenCookie(response.headers, token);
// __Host-token=...; Path=/; HttpOnly; Secure; SameSite=Lax
HttpOnly, Secure and SameSite are always emitted, there is no option
to remove them, and SameSite=None throws. The default name uses the
__Host- prefix, which binds the cookie to the exact origin and forbids
Domain.
To log someone out:
import { clearTokenCookie } from "lacewing";
clearTokenCookie(response.headers);
Both readers take WHATWG Headers, anything carrying them such as
Request, or the raw header string. Express, Fastify and Koa hand you a
plain object instead, so convert once at the edge:
app.use((req, res, next) => {
const headers = new Headers(req.headers as Record<string, string>);
req.token = readTokenCookie(headers) ?? parseBearer(headers);
next();
});
Passing the raw req throws a TypeError naming this fix. It does not read
as "no token".
The response side is a string, so it works anywhere:
res.setHeader("Set-Cookie", buildTokenCookie(token)); // Express
reply.header("Set-Cookie", buildTokenCookie(token)); // Fastify
c.header("Set-Cookie", buildTokenCookie(token)); // Hono
The most common token-confusion bug is a refresh token buying API access, or an access token minting new sessions. Both profiles ship with the library:
import {
accessTokenProfile, refreshTokenProfile,
newAccessToken, newRefreshToken,
} from "lacewing";
export const apiProfile = accessTokenProfile({
issuer: "https://auth.example.com",
audience: "https://api.example.com", // the API
algorithms: ["EdDSA"],
keys: { jwksUri: "https://auth.example.com/jwks" },
});
export const refreshProfile = refreshTokenProfile({
issuer: "https://auth.example.com",
audience: "https://auth.example.com/token", // the auth server, never the API
algorithms: ["EdDSA"],
keys: publicKey,
revocation: store, // long-lived means revocable
});
| access | refresh | |
|---|---|---|
typ |
at+jwt |
rt+jwt |
| audience | your API | your auth server's token endpoint |
| default max age | 10m | 30d |
| lifetime cap when signing | 1h | 90d |
| revocation | optional | required in practice |
Issuing:
const access = await newAccessToken()
.issuer(ISSUER).audience(API).subject("user-42").expiresIn("10m").sign(privateKey);
const refresh = await newRefreshToken()
.issuer(ISSUER).audience(`${ISSUER}/token`).subject("user-42").expiresIn("30d").sign(privateKey);
typ does the work. Even with identical keys, claims and audience, each
profile refuses the other's tokens:
// throws JWT_CLAIM_VALIDATION_FAILED: typ is rt+jwt, the profile demands at+jwt
await jwtVerify(refresh, apiProfile);
Every token gets a jti, so revocation always has a key. The in-memory
store suits a single process; implement the interface for anything else.
import { MemoryRevocationStore, defineProfile } from "lacewing";
const store = new MemoryRevocationStore();
const profile = defineProfile({
typ: "at+jwt",
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["EdDSA"],
keys: publicKey,
maxTokenAge: "15m",
revocation: store,
});
// On logout. jti and exp both come off the verified payload.
const { payload } = await jwtVerify(token, profile);
store.revoke(payload.jti as string, payload.exp as number);
await jwtVerify(token, profile); // now throws JWT_REVOKED
Entries are dropped once the token would have expired anyway, so the store does not grow without bound.
For a deployment, back it with Redis or your database:
import type { RevocationStore, TokenRevocationContext } from "lacewing";
class RedisRevocationStore implements RevocationStore {
constructor(private redis: Redis) {}
async isRevoked(ctx: TokenRevocationContext): Promise<boolean> {
if (ctx.jti === undefined) return false;
return (await this.redis.exists(`revoked:${ctx.jti}`)) === 1;
}
}
The context also carries sub, sid, exp and iat, so "revoke every
session for this user" needs no interface change.
A store that throws fails closed: the token is rejected because you cannot confirm it is not revoked. A Redis outage is therefore an auth outage. The opposite trade exists and is named to be conspicuous:
unsafeFailOpenOnRevocationError: true
Your API servers need the public key. In production that means a JWKS endpoint rather than shipping a key file to every service:
const profile = defineProfile({
typ: "at+jwt",
issuer: "https://auth.example.com",
audience: "https://api.example.com",
algorithms: ["EdDSA"],
keys: { jwksUri: "https://auth.example.com/.well-known/jwks.json" },
maxTokenAge: "15m",
});
Caching, Cache-Control handling, a fetch cooldown, a size cap and a
timeout apply by default. HTTPS is mandatory, redirects are never followed,
and symmetric keys are refused: a JWKS endpoint is public, so an oct key
published there is one any reader could mint with.
The knobs and their defaults:
keys: {
jwksUri: "https://auth.example.com/.well-known/jwks.json",
cacheTtlSeconds: 300,
cooldownSeconds: 30,
timeoutMs: 5000,
staleWhileErrorSeconds: 3600, // how long known-good keys serve during an outage
}
staleWhileErrorSeconds is bounded deliberately. A key rotated out because
it was compromised must not stay trusted for as long as an attacker can keep
your JWKS endpoint unreachable. Set it to 0 to disable stale serving.
Publishing the JWKS from the signing side:
import { exportKeyJWK } from "lacewing";
const jwks = { keys: [{ ...(await exportKeyJWK(publicKey)), kid: "2026-08" }] };
app.get("/.well-known/jwks.json", (_req, res) => res.json(jwks));
SignJWT emits only { alg, typ } in the header and cannot set a kid.
JWKS key selection matches on key type, curve and kid, so two keys of the
same algorithm in one JWKS are ambiguous for a Lacewing-signed token:
// A JWKS holding two EdDSA keys, the state you are in during a rotation.
await jwtVerify(token, profile);
// throws JWKS_NO_MATCHING_KEY: Multiple keys in the JWKS match this token -
// issue tokens with a kid
Since you cannot issue with a kid, plan around it. In order of preference:
Verify against an explicit key rather than a JWKS wherever you control
both ends. keys: publicKey has no ambiguity to resolve.
Rotate across algorithms. Publish the outgoing EdDSA key and the
incoming ES256 key together, allow both during the overlap, then drop
the old one. Tokens then select by alg:
const profile = defineProfile({
typ: "at+jwt", issuer: ISSUER, audience: AUDIENCE,
algorithms: ["EdDSA", "ES256"], // both, only during the overlap
keys: { jwksUri: "https://auth.example.com/.well-known/jwks.json" },
maxTokenAge: "15m",
});
Hard cutover. Publish one key at a time and accept that tokens signed with the old key fail for one token lifetime. At 10-minute access tokens that window is small.
Verifying other issuers' tokens is unaffected. Lacewing reads and validates
kid normally, so a JWKS from Auth0, Okta or Cognito works as expected. The
constraint applies only to tokens Lacewing signs.
Every refusal is a JWTError: one class, with a code from the
JWTErrorCode union saying why, and a message that starts with lacewing: .
The claim codes also set .claim to the claim's name. Messages are generic and
never echo token content, so they cannot become an oracle or a log-injection
vector.
An error that is not a JWTError did not come from Lacewing's checks: a
fetch you passed to createRemoteJWKSet that throws, for one, reaches you as
itself.
import { JWTError } from "lacewing";
try {
const { payload } = await jwtVerify(token, profile);
return handle(payload);
} catch (error) {
if (!(error instanceof JWTError)) throw error; // not a token problem; don't swallow it
if (error.code === "JWT_EXPIRED") return res.status(401).json({ error: "token_expired" });
logger.warn({ code: error.code, claim: error.claim }, "token rejected");
return res.status(401).json({ error: "invalid_token" });
}
The codes:
code |
Means |
|---|---|
JWT_INVALID |
Malformed, or the signature doesn't check out |
JWT_EXPIRED |
Past exp, or older than maxTokenAge |
JWT_CLAIM_VALIDATION_FAILED |
A claim is present but wrong (.claim names it) |
MISSING_CLAIM |
A required claim isn't there (.claim names it) |
ALGORITHM_NOT_ALLOWED |
alg isn't one Lacewing speaks, or isn't in your allowlist |
JWT_REVOKED |
The store says no |
REVOCATION_CHECK_FAILED |
The store broke; failing closed |
JWKS_FETCH_FAILED |
Couldn't fetch the JWKS with the global fetch |
JWKS_NO_MATCHING_KEY |
No key matched, or several did |
KEY_IMPORT_FAILED |
Key material couldn't be imported or generated |
KEY_TYPE_MISMATCH |
The key doesn't fit the algorithm |
KEY_EXPORT_FAILED |
The key couldn't be exported (not extractable, or a secret as PEM) |
ENTROPY_CHECK_FAILED |
HMAC secret is too weak; a startup problem |
PAYLOAD_HYGIENE_VIOLATION |
You're about to sign a secret (.claim names where) |
MAX_LIFETIME_EXCEEDED |
.expiresIn() is past the lifetime cap |
BEARER_PARSE_FAILED |
The Authorization header isn't exactly Bearer <token> |
Misconfiguration (a profile with no typ, an http: JWKS URL, a malformed
duration) is a TypeError at the call that builds the thing, not a
JWTError.
Do not return a distinct HTTP response per code to unauthenticated callers.
Use 401 invalid_token for all of them and keep the detail in logs.
jsonwebtokenThe mapping is mechanical except for two habits.
jsonwebtoken |
Lacewing |
|---|---|
jwt.sign(payload, key, opts) |
new SignJWT(typ).issuer().audience().expiresIn().sign(key) |
jwt.verify(token, key, opts) |
jwtVerify(token, profile) |
jwt.decode(token) |
unsafeDecode(token), branded untrusted |
| options per call site | one defineProfile shared by every call site |
algorithms: [...] optional |
mandatory, always |
Options move to the profile. Passing verification options at each call site
is how one endpoint ends up not checking audience. Define the profile once
and import it everywhere.
typ is mandatory. jsonwebtoken does not make you distinguish token
kinds, so most codebases have one sort of JWT doing several jobs. Lacewing
requires the kind to be named, and different kinds refuse each other.
Existing tokens without a typ have to be reissued; no profile accepts an
absent typ.
Migration order:
jwtVerify with a profile, behind a flag.typ, drop the old path.Step 3 waits one token lifetime.
"type": "module" and Node 24+ in your deploy imageEdDSA) unless you deliberately chose otherwisedefineProfile per token kind, at module scope, exportedaudience is the specific service, not a wildcardmaxTokenAge set to what you accept, not what the issuer claimsHttpOnly cookies; grep for localStorage to be surenew Headers(req.headers) at the edgekid constraint in
ยง10401 invalid_token for every rejection; codes go to logs onlyunsafeAllow, unsafeDecode and
unsafeFailOpenOnRevocationErrornpm run demo in the repository runs a live walkthrough where every
rejection is a real thrown error. For the full API, see the
README and the generated
TypeDoc.