Issuing JWT Sessions After a WebAuthn Assertion

A verified WebAuthn assertion is the end of the ceremony, not the end of the job: you now have to translate that momentary cryptographic proof into a durable session the rest of your application understands — typically a JWT plus a refresh token. Done carelessly, the session outlives the credential’s trust and cannot be revoked; done well, it carries the assurance level forward and can be killed the instant the passkey is. This page is the minting reference, within the server-side session management with passkeys cluster that is its parent.


Claim Reference

Claim Purpose Note
sub Account (opaque user handle) Never PII
cid credentialId reference the session is bound to Enables targeted revocation
aal Assurance level reached (aal2/aal3) Derived from UV + authenticator kind
amr Auth methods (["webauthn","user_verified"]) For downstream policy
exp Short access TTL (5–15 min) Refresh token carries the long TTL
jti Token id for blocklist revocation Backstop for pre-expiry kill

The aal claim should reflect the actual ceremony: UV set plus a hardware authenticator maps toward AAL3, per NIST SP 800-63B — the same mapping the compliance overview describes.


Root Cause Analysis (of unrevocable sessions)

1. Session not bound to the credential. A JWT with only sub cannot be selectively revoked when one of the user’s passkeys is lost — you must nuke every session or none.

2. Long-lived access tokens. A 24-hour access JWT ignores revocation for a day; short TTL + refresh is the fix.

3. AAL not carried forward. Downstream step-up checks cannot tell whether the session was UV-verified if the claim is missing.


Step-by-Step Resolution

Step 1 — Mint only after verification succeeds

const { verified, authenticationInfo } = await verifyAuthenticationResponse({ /* … */ });
if (!verified) throw Object.assign(new Error('assertion failed'), { status: 401 });
await bumpSignCount(credentialId, authenticationInfo.newCounter); // persist counter first

Persisting the new signCount before issuing the session closes the replay window described in interpreting signCount anomalies.

Step 2 — Issue a short access token bound to the credential

import { SignJWT } from 'jose';

const accessToken = await new SignJWT({
  cid: credentialRef,                 // stable reference to credentialId
  aal: uvVerified ? 'aal2' : 'aal1',
  amr: ['webauthn', ...(uvVerified ? ['user_verified'] : [])],
})
  .setProtectedHeader({ alg: 'EdDSA' })
  .setSubject(account.userHandle)
  .setJti(randomUUID())
  .setExpirationTime('10m')
  .sign(signingKey);

Step 3 — Persist a credential-bound session record

await cache.set(`session:${account.userHandle}:cred:${credentialId}:${jti}`, sessionMeta, { ttl: 60 * 60 * 8 });

The key layout is exactly what makes revoking a lost passkey a targeted delete.

Step 4 — Refresh against the still-valid credential

On refresh, confirm the bound credentialId is still ACTIVE (read from the primary) before issuing a new access token; if revoked, reject and force re-authentication.


Verification and Testing

// The session key encodes the credentialId for targeted purge
expect(sessionKey).toContain(`cred:${credentialId}`);
// Access token carries aal + cid
const { payload } = await jwtVerify(accessToken, pubKey);
expect(payload.cid).toBeDefined();
expect(payload.aal).toBe('aal2');

Assert that revoking the bound credential causes the next refresh to fail with 401. Confirm access tokens expire within the configured TTL. Verify no PII appears in sub or any claim.


Pitfalls

1. Unbound sessions. Bind to credentialId for selective revocation.

2. Long access TTLs. Use 5–15 min access + refresh bound to the credential.

3. Missing aal claim. Carry the assurance level for downstream step-up.


Related