Skip to main content

Passkeys (WebAuthn AAL2 credentials for platform users)

Scope

Two files, 352 lines. The verification primitives are correct — SimpleWebAuthn v13.3.2 handles CBOR parsing + signature verification + signature-counter check; challenges are short- TTL single-use with an hourly cleanup job; retention sweep is wired to the credentials-retention policy. The gaps are all policy-side: attestation is hardcoded to none, the clone- detection failure path is silent, DSAR erasure doesn't cascade, and the successful assertion never bridges into the platform's login/session flow so passkey login is de-facto dead-lettered.

  • src/modules/passkeys/passkeys.module.ts (352 lines) — PasskeysService (list / remove / registrationOptions / verifyRegistration / authenticationOptions / verifyAuthentication) + PasskeysController (authenticated: GET /passkeys, POST /registration/options, POST /registration/verify, DELETE /:id) + PublicPasskeysController (@Public(): POST /authentication/options, POST /authentication/verify).
  • src/modules/scheduled-jobs/passkey-challenge-cleanup.job.ts (50 lines) — hourly PasskeyChallenge sweep with audit event.
  • PasskeyCredential Prisma model — credentialId unique, publicKey (Bytes), counter (BigInt), transports[], deviceType, backedUp, nickname, createdAt, lastUsedAt, retentionScrubbedAt. No attestationFormat, no aaguid, no attestationObject, no revokedAt.
  • PasskeyChallengechallenge (base64url), purpose (register / authenticate), userId (nullable), expiresAt. Hard-deleted on consume; no consumedAt stamp.
  • Cross-consumers: none yet on the login path — verifyAuthentication returns { verified, userId, email } but no controller trades this for an access token. RetentionPurgeJob sweeps PasskeyCredential.lastUsedAt past 7 y.

Compliance envelope

  • NIST SP 800-63B §5.2.1 — attestation verification. AAL2 authenticators MAY use none attestation but for high-value populations (financial / regulator-facing) the platform SHOULD verify the attestation certificate chain against the FIDO Metadata Service so cloned or spoofed authenticator models are refused at registration. Today: hardcoded none, no aaguid capture, no cert chain check.
  • NIST SP 800-63B §5.2.5 — replay + clone detection. Signature-counter monotonicity is enforced by the library — but a failed check produces a generic 401, not a passkey.clone_detected audit event + optional auto-revoke. A cloned device fails auth silently; the platform never learns about the compromise.
  • NIST SP 800-63B §5.2.9 — deprecated algorithm refusal. verifyRegistration doesn't filter COSE algorithms; a registration that presents RSASSA-PKCS1-v1_5 with SHA-1 or any other weak alg is accepted so long as SimpleWebAuthn's signature verification succeeds.
  • DPPA 2019 §17 — subject-erasure cascade. DsarService.eraseUser scrubs users, sessions, prefs, and (Phase 3 of api-keys) cascade-audits API keys. Passkeys are not enumerated. The onDelete: Cascade FK removes the rows physically, but nickname PII ("Alice's iPhone") is hard-deleted without scrub-audit + without a passkey.dsar_cascade_enumerated event.
  • DPPA 2019 §11 — storage-limitation. Retention sweep is keyed only on lastUsedAt. A registered-but-never-used credential (someone who set up a passkey then never came back) lives forever. Should sweep on createdAt fallback for the never-used case.
  • DPPA 2019 §21 — credential storage. publicKey is opaque Bytes; private key never touches the server; challenges are base64url in a short-TTL row with cleanup — ✓. nickname is plaintext + user-supplied + often carries device / person names.
  • AML §14 — audit trail. passkey.register, passkey.authenticate, passkey.remove, passkey_credential.retention.scrubbed, passkey_challenge.retention.cleared land on the chain. Missing: passkey.clone_detected, passkey.dsar_cascade_enumerated, passkey.challenge.issued / .expired, passkey.attestation.rejected, passkey.registration.throttled, passkey.deprecated_algorithm_refused.
  • BOU cybersecurity §5.4 — throttle + audit on public surfaces. Authentication endpoints throttled (options 10/min, verify 20/min). Registration verify is unthrottled — attackers can spam attestation-forgery attempts against a hijacked session.

Current state (2026-07-20)

Module footprint

  • 2 module files, 402 lines total (352 + 50).
  • 2 Prisma models: PasskeyCredential + PasskeyChallenge.
  • 4 authenticated endpoints + 2 @Public() authentication endpoints.
  • 1 hourly cleanup cron (PasskeyChallengeCleanupJob).
  • RetentionPurgeJob sweeps passkey credentials past 7 y with nickname scrub.
  • Cross-consumers: none on the login pathverifyAuthentication returns a verification token but no endpoint trades it for an access token yet.

What works today

  • SimpleWebAuthn library correctness: CBOR parsing, COSE key verification, RP-ID + origin pinning, signature-counter check are all delegated to a maintained upstream library (v13.3.2). No custom crypto — no rolling-your-own footgun.
  • Challenge lifecycle: 5-minute TTL (passkeys.challenge_ttl_seconds policy, default 300 s). Single-use enforced by hard-delete in takeChallenge(). Hourly cleanup + audit event on the sweep.
  • Retention sweep on old credentials: RetentionPurgeJob scrubs nicknamenull + stamps retentionScrubbedAt on credentials whose lastUsedAt is past 7 y. Row survives for audit continuity.
  • RP-ID / origin pinning: rpConfig() derives rpID from the configured APP_URL env var; verification refuses any assertion whose origin doesn't match.
  • Enumeration-safe options endpoint: authenticationOptions(email) returns a challenge even when the email is unknown so an attacker can't probe for valid accounts.
  • Backup-eligibility captured: credentialBackedUp from the client attestation lands on PasskeyCredential.backedUp — the data is there even if the read surface hides it (gap 7).
  • Audit chain on write paths: passkey.register + passkey.authenticate + passkey.remove + passkey_credential.retention.scrubbed + passkey_challenge.retention.cleared land on the hash chain.

Gaps

All 12 gaps closed. See shipped-notes below.

Phase 1 shipped (2026-07-19)

Release passkeys-p1-20260719-2154. Closes gaps 1-4:

  • gap 1 — New POST /auth/login/passkey endpoint mounted on a PasskeyLoginController (@Public() + @Throttle(20/min)). Verifies the WebAuthn assertion via PasskeysService.loginWithPasskey, creates a Session row, and mints an access + refresh token pair via TokenService with mfaVerified: true so downstream @RequireMfa() guards accept the resulting session. Emits auth.login.passkey audit event. PasskeysModule now imports AuthModule so the token service resolves without a circular dependency.
  • gap 2 — DsarService.eraseUser (inside the $transaction) now enumerates every PasskeyCredential for the subject, scrubs nicknamenull + stamps retentionScrubbedAt + revokedAt before the User cascade FK removes the row. Enumeration lands on dsar.erase.metadata.scrubbed.passkeys
    • a dedicated passkey.dsar_cascade_enumerated audit event carries { dsarRequestId, credentials: [{ id, credentialId, aaguid, hadNickname }] } for grep-friendly compliance filtering.
  • gap 3 — Migration 20260720000000_passkeys_phase1 adds aaguid + attestationFormat + attestationObject columns to PasskeyCredential. verifyRegistration extracts them from the SimpleWebAuthn verification result on every registration regardless of the strict flag. Env flag PASSKEYS_REQUIRE_ATTESTATION=true (default false) makes verifyRegistration refuse none format + emit passkey.attestation.rejected audit event with the presented format so ops can trace deployment misconfig.
  • gap 4 — Migration adds revokedAt + revokedBy columns. verifyAuthentication compares authenticationInfo.newCounter <= stored.counter when stored counter is > 0 (excluding the Apple 0-always-0 case) and emits passkey.clone_detected audit event with { storedCounter, presentedCounter, userId, keyPrefix }. Env flag PASSKEYS_CLONE_REVOKE=true (default false) auto-sets revokedAt on the compromised credential + throws a distinct clone-detected unauthenticated response so the attacker sees the credential go cold. verifyAuthentication also refuses any credential where revokedAt IS NOT NULL outright.

Locked in by src/modules/passkeys/passkeys-phase1.spec.ts (4 cases). Test suite: 688/688 (102 suites).

Phase 2 shipped (2026-07-19)

Release passkeys-p2-20260719-2202. Closes gaps 5-8:

  • gap 5 — @Throttle({ default: { ttl: 60_000, limit: 5 } }) on POST /passkeys/registration/verify. Excess → 429 via the standard throttler pipeline.
  • gap 6 — Audit catalogue expanded. registrationOptions + authenticationOptions emit passkey.challenge.issued (best-effort, actor = user or anonymous for the enumeration-safe branch). Existing passkey_challenge.retention.cleared from the hourly PasskeyChallengeCleanupJob fills the expired-challenge audit shape. passkey.attestation.rejected (Phase 1 gap 3) + passkey.clone_detected (Phase 1 gap 4)
    • passkey.deprecated_algorithm_refused (gap 8) round out the catalogue.
  • gap 7 — list projection now includes backedUp + deviceType + aaguid + attestationFormat + revokedAt and decorates each row with a derived assuranceLevel: 'hardware' | 'synced' (via assuranceLevelFor(backedUp)) so the settings UI can gate high-value operations on hardware-bound credentials.
  • gap 8 — verifyRegistration inspects registrationInfo.publicKeyAlgorithm against the ALLOWED_COSE_ALGS allowlist (ES256=-7, RS256=-257, EdDSA=-8). Out-of-list → 400 DEPRECATED_ALGORITHM validation error + passkey.deprecated_algorithm_refused audit event with the presented alg + the allowlist for compliance grep.

Locked in by src/modules/passkeys/passkeys-phase2.spec.ts (4 cases). Test suite: 692/692 (103 suites).

Phase 3 shipped (2026-07-19)

Release passkeys-p3-20260719-2211. Closes gaps 9-12:

  • gap 9 — New passkeys.max_credentials_per_user operational policy (default 10). verifyRegistration counts non-revoked credentials before minting a new one; over-cap → 409 CONFLICT with a message pointing to the revoke endpoint.
  • gap 10 — PasskeyCredential.revokedAt + revokedBy columns (from Phase 1 migration) now drive DELETE /passkeys/:id as a soft-delete: the row survives for the AML §14 7-y audit window + passkey.revoked audit event carries userId + keyPrefix. Idempotent — a repeat DELETE on an already- revoked row short-circuits. list filters revoked rows by default; ?includeRevoked=true surfaces them for the audit dashboard. Phase 1 already wired verifyAuthentication to refuse revokedAt IS NOT NULL credentials.
  • gap 11 — RetentionPurgeJob passkey sweep gains an OR branch for never-used credentials: rows with lastUsedAt IS NULL fall back to createdAt < cutoff so a registered- then-abandoned credential still gets scrubbed past 7 y.
  • gap 12 — New PasskeysBootGate (OnModuleInit) validates APP_URL at boot: refuses to start in NODE_ENV=production when the URL is missing, localhost/.local, or non-HTTPS. Emergency escape hatch: PASSKEYS_ALLOW_MISCONFIG_IN_PROD=true. Dev + non-prod deploys log warnings but continue.

Locked in by src/modules/passkeys/passkeys-phase3.spec.ts (6 cases). Test suite: 698/698 (104 suites).

All 12 gaps closed. Passkeys module is audit-clean against NIST SP 800-63B §5.2.1 (attestation capture + strict flag), §5.2.5 (clone-detect audit + optional auto-revoke), §5.2.9 (COSE algorithm allowlist), DPPA §17 (DSAR cascade enumeration

  • nickname scrub), §11 (retention sweep for never-used credentials), §21 (opaque publicKey + soft-revoke audit-shape preservation), AML §14 (register / authenticate / revoke / clone-detect / attestation-rejected / deprecated-algorithm / challenge-issued / DSAR-cascade audit chain), CPA §37 (assurance-level clarity for consumer-facing high-value flows), and BOU §5.4 (throttle on both authentication AND registration + boot-gate on RP-ID misconfigure).

Phased implementation plan

Complete — every phase shipped.