Skip to main content

JwtTransport

WIP

Stateless 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:

  1. 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. Keep ttlMs short (5-15 minutes) and rely on refresh.
  2. 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

AlgSymmetric?Key shapeWhen to pick
HS256yes32+ byte secretSingle service or trusted-fleet.
ES256noEC P-256 keypairOIDC issuer, multi-tenant, DPoP.
RS256noRSA 2048+ keypairOIDC issuer where consumers expect RS256.
EdDSAnoEd25519 keypairNew 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 ->.