Credential Revocation and Account Recovery
When a passkey is compromised, lost, or administratively invalidated, every active session that trusts it becomes a potential entry point until the binding is severed. Unlike a password reset — which simply overwrites a hash — WebAuthn credential revocation must unwind a cryptographic state machine: marking the credentialId revoked in the primary store, purging derived session tokens, broadcasting invalidation signals to distributed nodes, and then re-grounding the account with a cryptographically sound recovery mechanism. This page covers production-ready patterns for all four stages, grounded in the W3C WebAuthn Level 2 specification and FIDO2 Security Requirements. For the overall architecture this fits into, see Backend Verification and Secure Credential Storage.
Concept Definition and Spec Grounding
The WebAuthn specification (W3C WebAuthn L2 §6.1) does not define a server-initiated revocation protocol. Revocation is entirely a relying party (RP) responsibility, enforced by refusing to accept assertions from a credentialId whose stored status is not ACTIVE. The spec does define two enforcement hooks the RP controls:
excludeCredentials(§5.4.3) — a list ofPublicKeyCredentialDescriptorobjects passed inPublicKeyCredentialCreationOptions. The authenticator refuses to create a new credential if its existing key ID matches any entry. Use this to prevent a revoked credential from being silently re-registered on a synced device.allowCredentials(§5.5) — a list passed inPublicKeyCredentialRequestOptions. Omitting a revokedcredentialIdfrom this list prevents assertion attempts from even reaching the signature-verification step.
The signCount field (authenticatorData bytes 33–36, big-endian uint32) is the spec’s primary anti-replay mechanism. During authentication verification logic, the server must reject any assertion whose signCount is not strictly greater than the stored value. A sudden drop to zero on a previously-active discoverable credential can indicate cloning; treat it as a revocation trigger.
Credential status state machine
A passkey credential moves through a finite set of states. Transitions must be atomic and logged:
ACTIVE → REVOKED (user request, admin override, compromise detection, policy expiry)
ACTIVE → SUSPENDED (temporary hold pending investigation; assertions rejected)
SUSPENDED → ACTIVE (investigation cleared)
SUSPENDED → REVOKED (investigation confirmed compromise)
REVOKED → ARCHIVED (post-retention purge to cold storage)
Architecture and Data Flow
The sequence below shows how a revocation request propagates from the initiating actor through the credential store, session layer, and event bus before the account enters a recoverable state.
Implementation Guide
Step 1 — Trigger identification and step-up verification
Revocation sources include user-initiated requests (lost device), automated anomaly detection (signCount regression, impossible-travel detection), administrative override, and policy expiry. Before mutating credential state, require proof of possession from a secondary factor. Accepting a revocation request without re-authentication opens a denial-of-service vector.
// src/auth/revocation.ts
import { verifyTOTP } from './mfa';
import { timingSafeEqual } from 'crypto';
interface RevokeRequest {
credentialId: string;
userId: string;
reason: 'USER_REQUEST' | 'COMPROMISE' | 'EXPIRY' | 'ADMIN_OVERRIDE';
stepUpProof: { type: 'totp' | 'backup_key'; value: string };
}
async function validateStepUp(userId: string, proof: RevokeRequest['stepUpProof']): Promise<void> {
if (proof.type === 'totp') {
const valid = await verifyTOTP(userId, proof.value);
if (!valid) throw Object.assign(new Error('Step-up MFA failed'), { status: 403 });
}
// Additional proof types: hardware security key assertion, backup passkey, etc.
}
W3C WebAuthn L2 §6.3.3 recommends requiring user verification (uv flag set) for sensitive RP operations. For revocation, treat step-up MFA as the equivalent enforcement point.
Step 2 — Atomic state transition with optimistic locking
Use a version column and a conditional WHERE clause to prevent concurrent revocation from corrupting state. The transaction must also write to the audit log; do not split these into separate statements.
// src/db/credentials.ts
import { db } from './connection'; // Prisma / pg / Drizzle ORM
export async function revokeCredential(
credentialId: string,
userId: string,
reason: string
): Promise<void> {
await db.transaction(async (tx) => {
const rows = await tx.$executeRaw`
UPDATE credentials
SET status = 'REVOKED',
revoked_at = NOW(),
revocation_reason = ${reason},
version = version + 1
WHERE id = ${credentialId}
AND user_id = ${userId}
AND status = 'ACTIVE' -- optimistic lock: only update if still ACTIVE
`;
if (rows === 0) {
throw Object.assign(
new Error('Credential not found or already revoked'),
{ status: 409 }
);
}
await tx.$executeRaw`
INSERT INTO audit_logs (event, credential_id, user_id, reason, created_at)
VALUES ('CREDENTIAL_REVOKED', ${credentialId}, ${userId}, ${reason}, NOW())
`;
});
}
The WHERE status = 'ACTIVE' guard is the critical line. Without it, a race between two concurrent revocation requests (e.g. user action + anomaly detector) may produce duplicate audit entries or reset the version counter inconsistently.
Step 3 — Session invalidation and cache purge
Revoking the credential record does not automatically invalidate JWTs or session cookies issued before the revocation. You must purge them explicitly. The safest pattern is to bind every session token to the credentialId at issuance, enabling targeted deletion without affecting unrelated sessions for the same user.
// src/session/invalidate.ts
import { cache } from './redis';
import { pubsub } from './events';
export async function invalidateCredentialSessions(
credentialId: string,
userId: string,
reason: string
): Promise<void> {
// Delete all session keys tagged with this credentialId
const keys = await cache.keys(`session:${userId}:cred:${credentialId}:*`);
if (keys.length > 0) await cache.del(...keys);
// Publish for edge nodes, API gateways, and microservices
await pubsub.publish('credential.revoked', {
credentialId,
userId,
reason,
revokedAt: new Date().toISOString(),
});
}
Subscribe to credential.revoked in every service that caches authentication state. API gateways should respond to this event by invalidating token-introspection caches for the affected credentialId.
Step 4 — Database schema for revocation tracking
Extend the credential indexing schema with columns that support status queries, archival, and GDPR-compliant purging.
-- Migration: add revocation columns to credentials table
ALTER TABLE credentials
ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ DEFAULT NULL,
ADD COLUMN IF NOT EXISTS revocation_reason VARCHAR(20)
CHECK (revocation_reason IN ('USER_REQUEST','COMPROMISE','EXPIRY','ADMIN_OVERRIDE')),
ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1;
-- Partial index: fast lookup of revoked credentials (used in excludeCredentials assembly)
CREATE INDEX IF NOT EXISTS idx_cred_revoked
ON credentials (user_id, revoked_at)
WHERE revoked_at IS NOT NULL;
-- Automated archival: move records revoked > 90 days ago to cold storage
CREATE TABLE IF NOT EXISTS credentials_archive (LIKE credentials INCLUDING ALL);
CREATE OR REPLACE FUNCTION archive_old_revoked_credentials() RETURNS void
LANGUAGE plpgsql AS $$
BEGIN
WITH moved AS (
DELETE FROM credentials
WHERE revoked_at IS NOT NULL
AND revoked_at < NOW() - INTERVAL '90 days'
RETURNING *
)
INSERT INTO credentials_archive SELECT * FROM moved;
END;
$$;
-- Schedule via pg_cron: SELECT cron.schedule('0 3 * * *', 'SELECT archive_old_revoked_credentials()');
Always populate excludeCredentials in PublicKeyCredentialCreationOptions using the union of ACTIVE and REVOKED records for the user. This prevents a passkey-syncing platform (iCloud Keychain, Google Password Manager) from silently re-registering a revoked credential on a newly enrolled device.
Step 5 — Secure recovery token generation
Recovery tokens must be cryptographically random (NIST SP 800-63B §5.1.2 requires at least 112 bits of entropy; use 256 for future-proofing), time-bound, and single-use. Store only the SHA-256 hash, never the raw token.
// src/recovery/tokens.ts
import { randomBytes, createHash } from 'crypto';
interface RecoveryTokenRecord {
userId: string;
tokenHash: Buffer; // only this goes in the DB
expiresAt: Date;
used: boolean;
}
export function generateRecoveryToken(): { raw: string; record: Omit<RecoveryTokenRecord, 'userId'> } {
const rawBytes = randomBytes(32); // 256 bits
const tokenHash = createHash('sha256').update(rawBytes).digest();
return {
raw: rawBytes.toString('base64url'), // sent to the user
record: {
tokenHash,
expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15-minute TTL
used: false,
},
};
}
export async function redeemRecoveryToken(
userId: string,
rawToken: string,
db: { recovery_tokens: { updateOne: Function } } // your ORM
): Promise<void> {
const tokenHash = createHash('sha256')
.update(Buffer.from(rawToken, 'base64url'))
.digest();
const updated = await db.recovery_tokens.updateOne({
where: { userId, tokenHash, used: false, expiresAt: { gt: new Date() } },
data: { used: true },
});
if (!updated) {
throw Object.assign(new Error('Invalid or expired recovery token'), { status: 403 });
}
}
After successful redemption, issue a scoped session with a narrow permission set — sufficient only to complete passkey re-registration via the FIDO2 registration flow — and revoke the scoped session immediately after the new authenticator is enrolled.
Validation Checklist
Server-side checks every revocation and recovery endpoint must pass:
Error Reference Table
| Error / Condition | HTTP Status | Trigger | Diagnostic |
|---|---|---|---|
| Credential not found | 404 | credentialId not in credentials table for this userId |
SELECT id, status FROM credentials WHERE id = $1 AND user_id = $2 |
| Already revoked | 409 | WHERE status = 'ACTIVE' returned 0 rows |
Check revoked_at column; ensure idempotent client retry logic |
| Step-up MFA failed | 403 | TOTP/backup-key proof rejected | Verify server clock skew < 30 s; check TOTP window size |
| Recovery token invalid | 403 | Hash mismatch, used = true, or expiresAt in the past |
Query recovery_tokens by userId; inspect used and expires_at columns |
| Stale read — revocation not visible | 200 (false positive) | Read replica lag serving ACTIVE status after revocation | Route revocation-status checks to the primary DB node; verify replica lag metric |
| Session purge incomplete | — | Redis DEL pattern missed keys due to key format mismatch |
Audit session key naming convention; use SCAN + DEL instead of pattern-based keys() in production |
| Re-registration blocked by excludeCredentials | InvalidStateError (client) |
Revoked credential still present on synced device | Confirm revoked credentialId appears in excludeCredentials on next registration options call |
Platform and Library Notes
@simplewebauthn/server
generateRegistrationOptions accepts excludeCredentials as a typed array of { id: Uint8Array; type: 'public-key' } objects. Build this from the union of ACTIVE and REVOKED credentials for the user to prevent re-registration on synced passkey platforms.
import { generateRegistrationOptions } from '@simplewebauthn/server';
const opts = await generateRegistrationOptions({
rpName: 'Example RP',
rpID: 'example.com',
userName: user.name,
excludeCredentials: allUserCredentials.map((c) => ({
id: c.credentialIdBytes,
type: 'public-key',
})),
});
fido2-lib
fido2-lib does not manage credential state; revocation logic lives entirely in your application. After verifying an assertion, compare the returned counter against your stored sign_count. If counter <= storedCount, call your revokeCredential() function immediately before responding.
py_webauthn
generate_registration_options accepts exclude_credentials: list[PublicKeyCredentialDescriptor]. Pass revoked credential IDs as bytes objects decoded from your database’s bytea column.
WebAuthn4J (Java/Kotlin)
Use DefaultChallengeRepository with a short TTL (60 s recommended). After revocation, call WebAuthnManager.verify() only against non-revoked credentials; filter the credential store before passing it to the authenticator data verifier.
iOS / iCloud Keychain
iCloud Keychain syncs passkeys with eventual consistency. A credential deleted on one Apple device may still appear on others for minutes. The only reliable server-side enforcement is rejecting assertions from revoked credentialId values during the authentication verification step. excludeCredentials at registration is a second line of defence.
Android / Google Password Manager
Google Password Manager syncs credentials across devices via cloud backup. Revoke server-side first; the sync propagation will not reflect the revocation until the authenticator attempts an assertion and the RP rejects it. For enterprise deployments, use the FIDO2 Management API (if available) to signal the credential manager directly.
Windows Hello
Windows Hello credentials are device-bound (no cross-device sync for platform credentials). Revocation at the RP is effective immediately because the credential cannot migrate. For roaming authenticators (CTAP2 keys used via Windows Hello), revocation follows the same eventual-consistency caveat as other platforms.
Pitfalls and Security Hardening
1. Orphaned JWTs and refresh tokens after revocation
Root cause: Revocation marks the credentialId as invalid in the credential store but does not reach long-lived JWT access tokens already in circulation.
Mitigation: Bind every session token to the credentialId at issuance time. On revocation, execute a targeted cache delete and publish credential.revoked to a middleware that adds the jti (JWT ID) to a token-revocation blocklist. Enforce short access-token TTLs (15 minutes maximum) as a backstop.
2. Stale read replicas serving ACTIVE status during high-load bursts
Root cause: Read replicas lag behind the primary by seconds to minutes under heavy write load. A revocation written to primary may not be visible on the replica serving authentication checks.
Mitigation: Route all revocation-status checks — specifically the WHERE status = 'ACTIVE' assertion check during allowCredentials filtering — to the primary database node. Use synchronous cache invalidation as an additional guard.
3. Recovery tokens delivered over attackable channels
Root cause: Sending a recovery token via SMS or email exposes it to SIM-swapping, email compromise, and network interception.
Mitigation: Prefer push-to-device notifications to a verified backup passkey or hardware token. If email is unavoidable, deliver only a confirmation link that triggers a push notification to a registered backup device, rather than including the raw token in the email body.
4. Social engineering against support staff
Root cause: Attackers impersonate legitimate users and request manual recovery via helpdesk channels, bypassing automated cryptographic controls.
Mitigation: Encode recovery policy as code (e.g. OPA/Rego policies). Require dual-approval for any admin-initiated recovery, log the approving employee’s identity in the immutable audit trail, and enforce just-in-time access for the admin recovery tool.
5. Infinite recovery loops via unscoped fallback sessions
Root cause: The session issued after recovery token redemption carries full application privileges, allowing repeated bypass of passkey enrollment.
Mitigation: Restrict the post-recovery session to a single endpoint: the FIDO2 registration endpoint. Expire the session after one successful enrollment or after five minutes, whichever comes first.
6. Re-registration of a revoked credential via passkey sync
Root cause: iOS or Android passkey sync platforms may retain a copy of a revoked credential and present it during a new registration attempt, effectively restoring access.
Mitigation: Always include the union of ACTIVE and REVOKED credentials in the excludeCredentials array. Set a long retention period for REVOKED records in your database before archiving them (90 days is a common compliance minimum).
Related
- Backend Verification and Secure Credential Storage — parent architecture covering the full credential lifecycle
- Revoking a Lost Passkey and Forcing Re-Authentication — targeted
credentialIdrevocation and session purge for a lost device - Building a Passkey Account Recovery Flow — recovery tokens, out-of-band delivery, and scoped re-enrollment sessions
- Designing Secure Registration Endpoints —
excludeCredentialsenforcement and attestation validation at registration - Implementing Authentication Verification Logic —
signCountanti-replay enforcement and assertion verification pipeline - Credential Indexing and Database Schema Design — base schema that revocation columns extend, including AAGUID and
credentialIdindexing - Server-Side Session Management with Passkeys — session token lifecycle and how revocation events propagate to session stores
- Handling Public Key Storage and Rotation — public key archival after revocation and COSE key format considerations