JwtTransport
WIPStateless JWT access token + opaque refresh cookie. HS256, ES256, RS256, EdDSA with live JWKS rotation and refresh-token family reuse detection.
When to use
JwtTransport issues a stateless JWT as the access token - every
request verifies the signature locally and reconstructs the session
without a store lookup. It is the right pick for:
- High-throughput APIs that cannot afford a session store hit per request.
- Microservice fleets where every service verifies independently.
- OIDC issuer posture (your service looks like an OIDC OP to downstream RPs).
JWT comes with two important caveats:
- You cannot revoke a JWT early. A revoked session is honoured by the
refresh-token cookie, but the access token continues to verify until
its
exp. KeepttlMsshort (5-15 minutes) and rely on refresh. - You need at least two verify keys in production so that a single key compromise can be rotated out without an audit-required restart.
Constructor
import { JwtTransport } from '@gentleduck/auth/core/transport'
new JwtTransport({
signKey: {
kid: 'kid-2026-05',
key: process.env.JWT_HS256_SECRET!, // 32+ bytes; mint with `duck-auth keys generate hs256`
},
verifyKeys: [
{ kid: 'kid-2026-05', key: process.env.JWT_HS256_SECRET! },
{ kid: 'kid-2026-04', key: process.env.JWT_HS256_PREV! }, // retired but still in-flight
],
issuer: 'https://api.example.com',
audience: 'duck-app',
ttlMs: 15 * 60 * 1000, // 15 minutes
refresh: {
cookieName: '__Host-duck-refresh',
ttlMs: 30 * 24 * 60 * 60 * 1000, // 30 days
},
})
Wire format
The JWT rides on Authorization: Bearer <jwt>. The refresh token rides
on the configured cookie (HttpOnly, Secure, SameSite=Strict).
Authorization: Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6...
Cookie: __Host-duck-refresh=01HJX7B2T9XK...
The JWT body carries the canonical session claims:
{
"sub": "identity-id",
"sid": "session-id",
"iss": "https://api.example.com",
"aud": "duck-app",
"iat": 1716800000,
"exp": 1716800900,
"aal": "aal2",
"amr": ["pwd", "totp"],
"tenant": "org-123"
}
Algorithm support
| Alg | Symmetric? | Key shape | When to pick |
|---|---|---|---|
HS256 | yes | 32+ byte secret | Single service or trusted-fleet. |
ES256 | no | EC P-256 keypair | OIDC issuer, multi-tenant, DPoP. |
RS256 | no | RSA 2048+ keypair | OIDC issuer where consumers expect RS256. |
EdDSA | no | Ed25519 keypair | New deployments - fastest verify. |
duck-auth pins alg per kid to defend against
algorithm-confusion attacks (where an attacker tries to verify an
RS256 token using its public key as an HS256 secret).
Key rotation
The rotation lifecycle is two-step:
# 1. Mint the new signer
bunx @gentleduck/auth keys rotate hs256
# -> prints new secret + the snippet to keep the prior kid on verifyKeys
Apply the snippet:
new JwtTransport({
signKey: { kid: 'kid-2026-06', key: process.env.JWT_HS256_NEW! }, // current
verifyKeys: [
{ kid: 'kid-2026-06', key: process.env.JWT_HS256_NEW! },
{ kid: 'kid-2026-05', key: process.env.JWT_HS256_PREV! }, // retired but in-flight
],
})
After the longest expected exp has passed (the JWT ttlMs), remove
the retired kid:
verifyKeys: [
{ kid: 'kid-2026-06', key: process.env.JWT_HS256_NEW! },
// kid-2026-05 removed
],
Live rotation without downtime: existing JWTs continue to verify against the retired kid; new JWTs are signed with the current kid.
Refresh tokens
The refresh token is an opaque cookie, not a JWT. duck-auth tracks it in the session store and rotates it on every use (RFC 6749 section 10.4 recommendation).
If the same refresh token is presented twice - once by the legit client,
once by an attacker who scraped it - duck-auth detects the family
reuse and revokes every refresh token descended from the same root.
The error code is AUTH/OAUTH_REUSE_DETECTED.
// On refresh:
POST /auth/refresh
Cookie: __Host-duck-refresh=...
// Response:
Set-Cookie: __Host-duck-refresh=<rotated>; ...
Authorization: Bearer <new jwt>
JWKS endpoint
For ES256 / RS256 / EdDSA, expose the public keys at a JWKS endpoint
so downstream services (or OIDC clients) can verify:
import { buildJwksHandler } from '@gentleduck/auth/oidc'
const jwks = buildJwksHandler(transport)
app.get('/.well-known/jwks.json', (req, res) => {
jwks(req).then((r) => res.json(r))
})
Pair with OIDC discovery for the full
/.well-known/openid-configuration document.
Combining with DPoP
JWT plus DPoP turns the bearer JWT into a sender-constrained token.
The JWT body carries a cnf.jkt claim - the thumbprint of the client's
public key - and the DPoP proof in the DPoP header binds the request
to that key:
import { JwtTransport, DPoPVerifier } from '@gentleduck/auth/core/transport'
const transport = new JwtTransport({ ... })
const dpop = new DPoPVerifier({ ... })
// Issue: cnf.jkt is added automatically if DPoPVerifier is wired.
// Verify: dpop.verify() rejects mismatched thumbprints.
See DPoP ->.