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
- Server-Side Session Management with Passkeys — the parent cluster on token lifecycle
- Step-Up Authentication with Passkeys — using the
aalclaim to gate sensitive actions - Revoking a Lost Passkey and Forcing Re-Authentication — how credential-bound sessions are purged