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) — hourlyPasskeyChallengesweep with audit event.PasskeyCredentialPrisma model —credentialIdunique,publicKey(Bytes),counter(BigInt),transports[],deviceType,backedUp,nickname,createdAt,lastUsedAt,retentionScrubbedAt. NoattestationFormat, noaaguid, noattestationObject, norevokedAt.PasskeyChallenge—challenge(base64url),purpose(register/authenticate),userId(nullable),expiresAt. Hard-deleted on consume; noconsumedAtstamp.- Cross-consumers: none yet on the login path —
verifyAuthenticationreturns{ verified, userId, email }but no controller trades this for an access token.RetentionPurgeJobsweepsPasskeyCredential.lastUsedAtpast 7 y.
Compliance envelope
- NIST SP 800-63B §5.2.1 — attestation verification. AAL2
authenticators MAY use
noneattestation 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: hardcodednone, noaaguidcapture, 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_detectedaudit 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.
verifyRegistrationdoesn'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.eraseUserscrubs users, sessions, prefs, and (Phase 3 of api-keys) cascade-audits API keys. Passkeys are not enumerated. TheonDelete: CascadeFK removes the rows physically, butnicknamePII ("Alice's iPhone") is hard-deleted without scrub-audit + without apasskey.dsar_cascade_enumeratedevent. - 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 oncreatedAtfallback for the never-used case. - DPPA 2019 §21 — credential storage.
publicKeyis opaqueBytes; private key never touches the server; challenges are base64url in a short-TTL row with cleanup — ✓.nicknameis 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.clearedland 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). RetentionPurgeJobsweeps passkey credentials past 7 y withnicknamescrub.- Cross-consumers: none on the login path —
verifyAuthenticationreturns 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_secondspolicy, default 300 s). Single-use enforced by hard-delete intakeChallenge(). Hourly cleanup + audit event on the sweep. - Retention sweep on old credentials:
RetentionPurgeJobscrubsnickname→null+ stampsretentionScrubbedAton credentials whoselastUsedAtis past 7 y. Row survives for audit continuity. - RP-ID / origin pinning:
rpConfig()derivesrpIDfrom the configuredAPP_URLenv 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:
credentialBackedUpfrom the client attestation lands onPasskeyCredential.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.clearedland on the hash chain.
Gaps
All 12 gaps closed. See shipped-notes below.
Release passkeys-p1-20260719-2154. Closes gaps 1-4:
- gap 1 — New
POST /auth/login/passkeyendpoint mounted on aPasskeyLoginController(@Public()+@Throttle(20/min)). Verifies the WebAuthn assertion viaPasskeysService.loginWithPasskey, creates aSessionrow, and mints an access + refresh token pair viaTokenServicewithmfaVerified: trueso downstream@RequireMfa()guards accept the resulting session. Emitsauth.login.passkeyaudit event.PasskeysModulenow importsAuthModuleso the token service resolves without a circular dependency. - gap 2 —
DsarService.eraseUser(inside the$transaction) now enumerates everyPasskeyCredentialfor the subject, scrubsnickname→null+ stampsretentionScrubbedAt+revokedAtbefore the User cascade FK removes the row. Enumeration lands ondsar.erase.metadata.scrubbed.passkeys- a dedicated
passkey.dsar_cascade_enumeratedaudit event carries{ dsarRequestId, credentials: [{ id, credentialId, aaguid, hadNickname }] }for grep-friendly compliance filtering.
- a dedicated
- gap 3 — Migration
20260720000000_passkeys_phase1addsaaguid+attestationFormat+attestationObjectcolumns toPasskeyCredential.verifyRegistrationextracts them from the SimpleWebAuthn verification result on every registration regardless of the strict flag. Env flagPASSKEYS_REQUIRE_ATTESTATION=true(defaultfalse) makesverifyRegistrationrefusenoneformat + emitpasskey.attestation.rejectedaudit event with the presented format so ops can trace deployment misconfig. - gap 4 — Migration adds
revokedAt+revokedBycolumns.verifyAuthenticationcomparesauthenticationInfo.newCounter <= stored.counterwhen stored counter is > 0 (excluding the Apple 0-always-0 case) and emitspasskey.clone_detectedaudit event with{ storedCounter, presentedCounter, userId, keyPrefix }. Env flagPASSKEYS_CLONE_REVOKE=true(defaultfalse) auto-setsrevokedAton the compromised credential + throws a distinctclone-detectedunauthenticated response so the attacker sees the credential go cold.verifyAuthenticationalso refuses any credential whererevokedAt IS NOT NULLoutright.
Locked in by src/modules/passkeys/passkeys-phase1.spec.ts
(4 cases). Test suite: 688/688 (102 suites).
Release passkeys-p2-20260719-2202. Closes gaps 5-8:
- gap 5 —
@Throttle({ default: { ttl: 60_000, limit: 5 } })onPOST /passkeys/registration/verify. Excess → 429 via the standard throttler pipeline. - gap 6 — Audit catalogue expanded.
registrationOptions+authenticationOptionsemitpasskey.challenge.issued(best-effort, actor = user oranonymousfor the enumeration-safe branch). Existingpasskey_challenge.retention.clearedfrom the hourlyPasskeyChallengeCleanupJobfills 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 —
listprojection now includesbackedUp+deviceType+aaguid+attestationFormat+revokedAtand decorates each row with a derivedassuranceLevel: 'hardware' | 'synced'(viaassuranceLevelFor(backedUp)) so the settings UI can gate high-value operations on hardware-bound credentials. - gap 8 —
verifyRegistrationinspectsregistrationInfo.publicKeyAlgorithmagainst theALLOWED_COSE_ALGSallowlist (ES256=-7,RS256=-257,EdDSA=-8). Out-of-list → 400DEPRECATED_ALGORITHMvalidation error +passkey.deprecated_algorithm_refusedaudit 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).
Release passkeys-p3-20260719-2211. Closes gaps 9-12:
- gap 9 — New
passkeys.max_credentials_per_useroperational policy (default 10).verifyRegistrationcounts non-revoked credentials before minting a new one; over-cap → 409CONFLICTwith a message pointing to the revoke endpoint. - gap 10 —
PasskeyCredential.revokedAt+revokedBycolumns (from Phase 1 migration) now driveDELETE /passkeys/:idas a soft-delete: the row survives for the AML §14 7-y audit window +passkey.revokedaudit event carriesuserId+keyPrefix. Idempotent — a repeat DELETE on an already- revoked row short-circuits.listfilters revoked rows by default;?includeRevoked=truesurfaces them for the audit dashboard. Phase 1 already wiredverifyAuthenticationto refuserevokedAt IS NOT NULLcredentials. - gap 11 —
RetentionPurgeJobpasskey sweep gains anORbranch for never-used credentials: rows withlastUsedAt IS NULLfall back tocreatedAt < cutoffso a registered- then-abandoned credential still gets scrubbed past 7 y. - gap 12 — New
PasskeysBootGate(OnModuleInit) validatesAPP_URLat boot: refuses to start inNODE_ENV=productionwhen 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.