From a6807850238a66fff4e6d34dde25614440e44174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 27 May 2026 07:39:51 +0200 Subject: [PATCH] fix(auth): require user verification on every passkey ceremony MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `generateRegistrationOptions` and `generateAuthenticationOptions` were both passing `userVerification: "preferred"`. The WebAuthn spec treats `preferred` as a soft hint — an authenticator that lacks (or chooses to skip) a UV factor returns an assertion with `userVerified: false`, and SimpleWebAuthn accepts it as a successful ceremony. The registration carries the same `userVerified: false` flag forever, so a credential created without a biometric / PIN / fingerprint check cannot reach AAL3 on any later assertion either. Flipping both call sites to `"required"` makes the UV check mandatory at the authenticator-policy layer. Apple's platform authenticators (Touch ID / Face ID / passcode) and every shipped hardware authenticator support UV, so the user-visible flow is unchanged for every realistic device. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/auth/passkey.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/lib/auth/passkey.ts b/src/lib/auth/passkey.ts index fe6c3a338..4ebb661a0 100644 --- a/src/lib/auth/passkey.ts +++ b/src/lib/auth/passkey.ts @@ -140,7 +140,13 @@ export async function createRegistrationOptions( })), authenticatorSelection: { residentKey: "preferred", - userVerification: "preferred", + // `required` (vs `preferred`) instructs the authenticator that + // a UV check (biometric / PIN / fingerprint) is mandatory for + // every registration ceremony. A `preferred` posture lets a + // device that lacks a UV factor silently downgrade — and the + // resulting passkey carries the same `userVerified: false` flag + // forever, blocking any future AAL3-style assertion against it. + userVerification: "required", }, }); @@ -216,7 +222,11 @@ export async function createAuthenticationOptions(userId?: string) { const options = await generateAuthenticationOptions({ rpID: getRpId(), - userVerification: "preferred", + // Match the registration posture — see `createRegistrationOptions` + // above for the rationale. Refusing to honour a `userVerified: + // false` assertion keeps the AAL3 floor that the registration + // policy now enforces. + userVerification: "required", allowCredentials, });