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