Revoking a Lost Passkey and Forcing Re-Authentication
When a user reports a lost or stolen device, the goal is precise: stop the compromised passkey from authenticating, kill its live sessions, and force re-authentication — without logging the user out of their other devices or locking them out entirely. This is a focused procedure within the broader lifecycle covered by credential revocation and account recovery, which is its parent. This page is the exact server-side runbook.
State and Error Reference
| Condition | HTTP Status | Meaning |
|---|---|---|
| Credential not found | 404 | credentialId not owned by this user |
| Already revoked | 409 | WHERE status='ACTIVE' matched 0 rows |
| Step-up required | 403 | Revocation attempted without re-auth |
| Revoked, sessions purged | 204 | Success — assertions now rejected |
| Assertion after revocation | 401 | allowCredentials filtering excluded it |
The controlling spec hooks are allowCredentials (omit the revoked credentialId) and excludeCredentials (block re-registration of a synced copy), per WebAuthn L2 §5.4.3–§5.5.
Root Cause Analysis (of incomplete revocation)
1. Sessions outlive the credential. Marking the credentialId revoked does nothing to JWTs already issued. If sessions are not bound to the credential, the attacker’s live session survives.
2. Read-replica lag serves stale ACTIVE status. An authentication check hitting a lagging replica may still see ACTIVE and accept the revoked credential.
3. Synced copy re-registers. A passkey synced to a still-signed-in device can be re-presented at registration and effectively restored unless added to excludeCredentials.
Step-by-Step Resolution
Step 1 — Require step-up before revoking
Never revoke on an unauthenticated request; require a fresh assertion from another passkey or a recovery factor (a DoS and takeover guard).
await validateStepUp(userId, proof); // TOTP, backup passkey, or admin dual-approval
Step 2 — Atomically mark revoked with an optimistic lock
const rows = await db.$executeRaw`
UPDATE credentials
SET status = 'REVOKED', revoked_at = NOW(), revocation_reason = 'COMPROMISE', version = version + 1
WHERE id = ${credentialId} AND user_id = ${userId} AND status = 'ACTIVE'`;
if (rows === 0) throw Object.assign(new Error('Not found or already revoked'), { status: 409 });
Step 3 — Purge sessions bound to that credential
Because each session token carries its originating credentialId, deletion is targeted — the user’s other passkeys keep their sessions.
const keys = await cache.keys(`session:${userId}:cred:${credentialId}:*`);
if (keys.length) await cache.del(...keys);
await pubsub.publish('credential.revoked', { userId, credentialId, at: new Date().toISOString() });
Step 4 — Route status checks to the primary and update excludeCredentials
Read revocation status from the primary DB (not a replica) during allowCredentials filtering, and add the revoked credentialId to excludeCredentials for future registrations so a synced copy cannot re-enrol.
Step 5 — Confirm the user retains access
Verify the user still has at least one active passkey or a recovery path; if the lost passkey was their only credential, route them into account recovery instead of leaving them locked out.
Verification and Testing
# The revoked credential must no longer authenticate
curl -sX POST /api/assertion/verify -d @revoked_assertion.json -o /dev/null -w "%{http_code}\n"
# expect 401
Assert that revoking passkey A invalidates only A’s sessions and leaves passkey B’s sessions active. Force replica lag in a test and confirm status checks hit the primary. Attempt to re-register the synced credential and confirm excludeCredentials blocks it with InvalidStateError.
Pitfalls
1. Unbound sessions survive revocation. Bind every session to its credentialId.
2. Stale replica accepts the revoked credential. Route status checks to the primary.
3. Locking out a single-credential user. Offer recovery before completing revocation when it is their only passkey.
Related
- Credential Revocation and Account Recovery — the parent lifecycle and revocation state machine
- Building a Passkey Account Recovery Flow — the path for a user who lost their only passkey
- Server-Side Session Management with Passkeys — binding sessions to
credentialIdfor targeted purge