Skip to content

fix(auth): validate passkey verify request payloads - #8619

Merged
iscekic merged 3 commits into
mainfrom
drain/passkey-verify-payload-validation
Oct 22, 2026
Merged

iscekic merged 3 commits into
mainfrom
drain/passkey-verify-payload-validation

Conversation

@iscekic

@iscekic iscekic commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Evidence

Evidence: demonstrated — the passkey Playwright spec ran against the local cloud dev stack (Next.js on the VM, local Postgres, Chromium virtual authenticator): registration and sign-in with a real browser credential still succeed, a replayed assertion is refused with 401, a well-formed assertion signed for another challenge is refused with 401, and a body that is not a WebAuthn response gets 400 from both verify routes instead of reaching the verifier. Route unit tests in both route directories cover the malformed and well-formed bodies, and a schema test covers native responses whose absent optional members are serialized as null.

What changed

The passkey authenticate and register routes now validate the WebAuthn response they accept before handing it to the library, and the as unknown as assertions are gone.

  • New apps/web/src/lib/auth/passkey-response.ts exports registrationResponseSchema and authenticationResponseSchema, typed as z.ZodType<RegistrationResponseJSON> and z.ZodType<AuthenticationResponseJSON>. They require the credential id and rawId as non-empty strings, type as 'public-key', response.clientDataJSON plus attestationObject (registration) or authenticatorData and signature (assertion) as non-empty strings, and the optional transports/public-key and user-handle members.
  • The optional response members accept null and normalize it to absence. The iOS and Android passkey bridges emit null for an optional member they did not set, so a real native registration or assertion reaches the verifier instead of being refused as a bad request. A required member still rejects null.
  • clientExtensionResults is validated as an object when present and defaults to {}. A native security-key assertion omits it; this relying party's verification never reads extension outputs.
  • authenticatorAttachment is restricted to platform or cross-platform; a string userHandle is accepted as-is because verification ignores a handle it cannot use.
  • Unknown keys are stripped, so a field the boundary does not know cannot travel into the ceremony helper.
  • Both routes use the matching schema in the verify branch of their existing discriminated union and call verifyAuthentication / verifyRegistration with the parsed value directly. A body that fails the schema returns the routes' existing 400 { error: 'INVALID_REQUEST' }.

Verification keeps belonging to @simplewebauthn/server: verifyRegistrationResponse and verifyAuthenticationResponse decode the client data, compare the challenge, origin and rpId, and check the signature against the stored public key. The existing defensive checks inside apps/web/src/lib/auth/passkey.ts are unchanged.

Tests

  • apps/web/src/app/api/auth/passkey/authenticate/route.test.ts and .../register/route.test.ts (13 tests): well-formed response reaches the ceremony helper; a body that is not a WebAuthn response (inner response only, missing rawId, wrong type, non-string members, missing attestation object) returns 400 and never reaches the helper; a native assertion without extension results is accepted and normalized; refused verification still returns 401 with the stable code.
  • apps/web/src/lib/auth/passkey-response.test.ts: native responses whose optional members are null normalize to absence, and a null required member is refused.
  • apps/web/tests/e2e/passkey.spec.ts: the registration boundary rejects a malformed attestation with 400, the sign-in flow still registers and signs in with a real credential, replay and wrong-challenge stay 401, and a malformed assertion body returns 400.

Local checks: jest src/lib/auth/passkey src/app/api/auth/passkey (58 passed), pnpm --filter web typecheck (clean), oxlint and oxfmt --check on the changed files (clean).

Proxy answers

  1. Question: Extensions: The typed WebAuthn response requires clientExtensionResults, but the native security-key assertion shape omits it (and verification never reads it). How should the route schema treat it?
    Answer: Optional object, default {}
    Confidence: 0.70
  2. Question: Unknown keys: What should the schema do with unknown keys in the response object?
    Answer: Strip them
    Confidence: 0.70
  3. Question: E2E case: The Playwright spec's wrong-challenge case posts only the inner response object, which the schema now refuses. How should that case change?
    Answer: Resend the captured body with the fresh challengeId
    Confidence: 0.75
  4. Question: Verification: How far should end-to-end verification go on this VM?
    Answer: Route tests plus the Playwright passkey spec
    Confidence: 0.85

The passkey authenticate and register routes accepted the WebAuthn
response as an arbitrary object and then asserted it into the library's
response type, so the request boundary did not check that the body was a
credential response.

Add zod schemas for the registration and authentication response fields
the routes hand to @simplewebauthn/server, use them in the verify branch
of both routes, and drop the double casts. The library keeps performing
every cryptographic check: it decodes the client data, compares the
challenge, origin and rpId, and verifies the signature against the
stored public key. A body that is not a WebAuthn response gets the
routes' existing 400 instead of reaching the verifier.

Unknown keys are stripped, client extension outputs are normalized to an
empty object when a native authenticator omits them, and any string user
handle is accepted because the verifier ignores one it cannot use.

Route tests cover a well-formed response, a body shaped like something
else, and the native shape without extension results. The Playwright
passkey spec exercises the same boundary against a real browser
credential and keeps the wrong-challenge refusal by resending the
captured assertion with a fresh challenge id.
@iscekic iscekic added drain Work item from the findings drain janitor:maintainability Maintainability finding from the janitor labels Oct 22, 2026
@kilo-code-bot

kilo-code-bot Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Executive Summary

The incremental change narrows the new passkey response schemas so native bridges that serialize absent optional WebAuthn members as null are normalized to undefined (and absent extension outputs to {}) instead of being refused with 400; nothing in the changed code weakens the verification path.

Assumptions: [INFERENCE] node_modules is absent in this read-only sandbox, so the Zod 4.6.7 nullish().transform(...) typing against the z.ZodType<RegistrationResponseJSON> / z.ZodType<AuthenticationResponseJSON> annotations was reasoned from the schema definitions and the author's reported clean typecheck rather than re-run. [INFERENCE] The premise that the native bridge emits null (not omitted keys) for absent optional fields is taken from the PR body and the route tests; no in-repo fixture proves it, and if a bridge instead nulled a required member (id, rawId, clientDataJSON, attestationObject, authenticatorData, signature), the effect is a client-side 400 refusal, not a bypass. [INFERENCE] The normalization only affects members that apps/web/src/lib/auth/passkey.ts does not read (it consumes clientDataJSON, id, and the library-verified signature), so no authorization or challenge check changes.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 0
Files Reviewed (2 files, incremental scope)
  • apps/web/src/lib/auth/passkey-response.ts
  • apps/web/src/lib/auth/passkey-response.test.ts

Scope: incremental review of 7a0f49c76..2cc6415cd. The unchanged files from the earlier commit were not re-analyzed; the previously reviewed findings remain resolved.

Previous Review Summary (commit 7a0f49c)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 7a0f49c)

Status: No Issues Found | Recommendation: Merge

Executive Summary

The PR replaces the permissive z.record passkey response boundary with typed zod schemas in both verify routes, and the changed code preserves every verification path while rejecting structurally invalid WebAuthn bodies; no defect was found in the reviewed scope.

Assumptions: [INFERENCE] Dependency type definitions could not be inspected because node_modules is absent in this sandbox, so the compile-time compatibility of registrationResponseSchema / authenticationResponseSchema with the annotated RegistrationResponseJSON / AuthenticationResponseJSON types (for example transports: z.array(z.string())) was reasoned from surrounding code rather than a fresh typecheck. [INFERENCE] Native client output shapes were inferred from apps/mobile/src/lib/auth/passkey-client.ts, which forwards the react-native-passkeys object unchanged; no in-repo fixture proves an authenticatorAttachment: null or non-string userHandle is ever emitted, and if one were, the effect would be a client-side 400 refusal rather than a security bypass.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 0
Files Reviewed (6 files)
  • apps/web/src/lib/auth/passkey-response.ts
  • apps/web/src/app/api/auth/passkey/authenticate/route.ts
  • apps/web/src/app/api/auth/passkey/authenticate/route.test.ts
  • apps/web/src/app/api/auth/passkey/register/route.ts
  • apps/web/src/app/api/auth/passkey/register/route.test.ts
  • apps/web/tests/e2e/passkey.spec.ts

Notes: the schemas only narrow the untrusted input before verifyRegistration / verifyAuthentication; challenge consumption, origin/rpId checks, signature verification, credential ownership and the counter compare-and-set remain in apps/web/src/lib/auth/passkey.ts. The new 400 path fires before consumeChallenge, so a malformed body cannot burn a challenge. Registration still binds to the session user, and the kiloUserId stripping test remains meaningful.


Reviewed by deepseek-flash-latest · Input: 59.5K · Output: 11.8K · Cached: 553.6K

Review guidance: REVIEW.md from base branch main

@iscekic iscekic added the merge:human Substantive functional risk requires human review. label Oct 22, 2026
@iscekic iscekic added merge:blocked:judge-unavailable Risk assessment unavailable merge:bot Bot-owned review; merge when ready once readiness passes and a review approves. and removed merge:human Substantive functional risk requires human review. labels Oct 22, 2026
The mobile passkey bridge serializes absent optional fields as null. The
response schemas now normalize null optional fields before verification, so a
native registration or assertion reaches the verifier instead of being refused
as an invalid request. Required credential members still reject null.
@iscekic iscekic removed merge:blocked:judge-unavailable Risk assessment unavailable merge:bot Bot-owned review; merge when ready once readiness passes and a review approves. labels Oct 22, 2026
@iscekic

iscekic commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

bot: Native verification found one real defect in the previous head and it is fixed here.

Defect. The mobile passkey bridge serializes absent optional WebAuthn fields as null rather than omitting them. Both new schemas accepted only omission, so a native attestation or assertion was refused with INVALID_REQUEST before reaching the verifier. Registration and passkey sign-in would have failed on a real device.

Fix. Optional response members now normalize null to absence, and absent client extension outputs normalize to an empty object. Required members still reject null, and unknown keys are still stripped.

Verification on the fixed head.

  • Server boundary, against minted challenges on the isolated stack: native-shaped registration and assertion bodies reach the verifier and are refused on the challenge, while a null required member is still a bad request.
  • Passkey Playwright spec on the isolated stack: registration, sign-in, replay refusal, wrong-challenge refusal, and the no-credential refusal all pass.
  • Route and schema tests pass, along with web typecheck and lint.

Device coverage. Android cancellation and recovery paths were exercised through the real OS Credential Manager sheet on a Play-image emulator: the sheet appears from both the sign-in and registration entry points, dismissal shows the retryable message, and a retry reopens the sheet. A local credential cannot be created on that emulator because no Google account is signed in, so the platform offers only cross-device creation. The iOS simulator could not install a client: the supported install path requires a simulator claim owned by the workflow harness, which this workstation does not have, and I did not bypass that gate.

Product may confirm the happy path on TestFlight.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

drain Work item from the findings drain janitor:maintainability Maintainability finding from the janitor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants