Skip to content

feat(clerk-js,ui,shared,localizations): Support passkey as a second factor - #9127

Open
dmoerner wants to merge 31 commits into
mainfrom
daniel/user-3966-passkey-as-2fa-option
Open

dmoerner wants to merge 31 commits into
mainfrom
daniel/user-3966-passkey-as-2fa-option

Conversation

@dmoerner

@dmoerner dmoerner commented Jul 10, 2026 •

Copy link
Copy Markdown
Contributor

Users who have a passkey and sign in with a password (or OAuth, or a reset-password code) cannot use that passkey as their second factor: the SDK treats passkeys purely as a first-factor strategy, and the prebuilt UI's factor-two step throws on any second-factor strategy it doesn't know. The Clerk backend (clerk/clerk_go#20312) can now offer passkey in a sign-in's and a session reverification's supportedSecondFactors when the instance allows passkeys to satisfy the second factor and the user has a registered passkey. This adds client support end to end:

  • Types: passkey variants in SignInSecondFactor, Prepare/AttemptSecondFactorParams, SessionVerificationSecondFactor, and the session verify param unions.
  • signIn.authenticateWithPasskey() detects needs_second_factor (and needs_client_trust) and runs the prepare/attempt second-factor flow against the in-progress sign-in instead of creating a new one; the flow param is ignored there. attemptSecondFactor serializes the WebAuthn credential like attemptFirstFactor. The future API gains mfa.passkey().
  • session.verifyWithPasskey({ level: 'second_factor' }) completes a multi-factor step-up with a passkey.
  • Prebuilt UI: passkey cards for the sign-in factor-two and user-verification factor-two steps. The starting-factor ladder is now passkey → TOTP → phone code, so a user with both a passkey and TOTP lands on the passkey card and reaches the authenticator app via "Use another method"; the passkey card is hidden when WebAuthn is unsupported. Fixes the alternative-methods label switch that threw on unknown second-factor strategies.
  • New signIn.passkeyMfa / reverification.passkeyMfa localization keys.

This relies on the FAPI API version bump to 2026-08-20 (SUPPORTED_FAPI_VERSION): the backend only offers the passkey second factor to SDKs that request at least that version, so older clerk-js builds keep their current behavior with no changes.

Part of USER-3966

Tested end to end against local clerk running clerk/clerk_go#20312 (real WebAuthn ceremonies via a Chromium virtual authenticator), comparing this branch with the published clerk-js 6.29.2 (2026-05-12):

  • Old API version (published clerk-js): passkey is never offered in sign-in or reverification; TOTP, email-code Client Trust, and TOTP reverification all still complete unchanged.
  • Sign-in: password → passkey and reset-password email code → new password → passkey via authenticateWithPasskey() on the in-progress sign-in; first factor stays password / reset_password_email_code.
  • Client Trust: on a new device supportedSecondFactors is email_code + passkey, and authenticateWithPasskey() completes the sign-in.
  • Reverification: verifyWithPasskey({ level: 'second_factor' }), multi_factor (password → passkey; passkey as first factor alone also completes), first_factor, and the prebuilt reverification modal (starts on the passkey card, afterVerification fires).
  • Prebuilt <SignIn />: factor-two step starts on the passkey card, "Use another method" lists "Sign in with your passkey" and "Use your authenticator app", and the flow ends signed in.
  • Abandoning the passkey attempt leaves the sign-in / reverification at needs_second_factor with no session; an authenticator without a matching credential rejects and creates no session.
  • Passkey alone does not create a 2FA requirement (twoFactorEnabled stays false; password sign-in completes once TOTP is removed).
  • TOTP still works alongside passkey; fva is [0,0] after TOTP, passkey-2FA, and passkey-first sign-ins.
  • No regressions in passkey first-factor flows (discoverable sign-in, passkey autofill).
  • Not exercised locally: OAuth → passkey (no provider; covered by backend integration tests).

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

…actor

The Clerk backend can now offer passkey in a sign-in's (and session
reverification's) supported_second_factors when the instance allows
passkeys to satisfy the second factor and the user has a registered
passkey. This adds client support end to end:

- Types: passkey variants in SignInSecondFactor,
  Prepare/AttemptSecondFactorParams, SessionVerificationSecondFactor,
  and the session verify param unions.
- signIn.authenticateWithPasskey() detects needs_second_factor (and
  needs_client_trust) and runs the prepare/attempt second-factor flow
  against the in-progress sign-in instead of creating a new one; the
  flow param is ignored there. attemptSecondFactor serializes the
  WebAuthn credential like attemptFirstFactor. The future API gains
  mfa.passkey().
- session.verifyWithPasskey({ level: 'second_factor' }) completes a
  multi-factor step-up with a passkey.
- Prebuilt UI: passkey cards for the sign-in factor-two and
  user-verification factor-two steps, offered via the existing
  totp/phone-first ladder (passkey is only the starting factor when it
  is the sole option), listed under "Use another method", and hidden
  when WebAuthn is unsupported. Fixes the alternative-methods label
  switch that threw on unknown second-factor strategies.
- New signIn.passkeyMfa / reverification.passkeyMfa localization keys.

Part of USER-3966

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 10, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5d55d7a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 23 packages
Name Type
@clerk/shared Minor
@clerk/clerk-js Minor
@clerk/ui Minor
@clerk/localizations Minor
@clerk/react Minor
@clerk/astro Patch
@clerk/backend Patch
@clerk/chrome-extension Patch
@clerk/electron Patch
@clerk/expo-passkeys Patch
@clerk/expo Patch
@clerk/express Patch
@clerk/fastify Patch
@clerk/hono Patch
@clerk/mosaic Patch
@clerk/msw Patch
@clerk/nextjs Patch
@clerk/nuxt Patch
@clerk/react-router Patch
@clerk/swingset Patch
@clerk/tanstack-react-start Patch
@clerk/testing Patch
@clerk/vue Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 10, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
clerk-js-sandbox Ready Ready Preview Oct 16, 2026 4:42pm UTC
swingset Ready Ready Preview Oct 16, 2026 4:42pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Jul 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Passkey second-factor support is added to sign-in and session reverification APIs, shared types, prebuilt sign-in and user-verification UIs, tests, localization resources, FAPI versioning, and release metadata.

Changes

Area Summary
Contracts and resource flows Shared types, errors, session verification, sign-in MFA methods, WebAuthn serialization, and first- and second-factor API flows support passkeys.
Sign-in and user-verification UI Passkey cards, factor selection, WebAuthn filtering, alternative methods, abort handling, reset-password routing, fixtures, and tests are added or updated.
Localization and release metadata Passkey MFA localization keys are added across locales, the supported FAPI version is updated, and release notes record the new APIs and UI flows.

Estimated code review effort: 4 (Complex) | ~60 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely identifies the main change: adding passkey support as a second factor across the listed packages.
Description check ✅ Passed The description directly explains the passkey second-factor feature, affected APIs, UI changes, localization updates, testing, and fallback behavior.

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 10, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@9127

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@9127

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@9127

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@9127

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9127

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9127

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9127

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@9127

@clerk/expo-biometrics

npm i https://pkg.pr.new/@clerk/expo-biometrics@9127

@clerk/expo-google-signin

npm i https://pkg.pr.new/@clerk/expo-google-signin@9127

@clerk/expo-native-components

npm i https://pkg.pr.new/@clerk/expo-native-components@9127

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@9127

@clerk/express

npm i https://pkg.pr.new/@clerk/express@9127

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@9127

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@9127

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@9127

@clerk/mosaic

npm i https://pkg.pr.new/@clerk/mosaic@9127

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@9127

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@9127

@clerk/react

npm i https://pkg.pr.new/@clerk/react@9127

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@9127

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@9127

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@9127

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@9127

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@9127

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@9127

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@9127

commit: 5d55d7a

@github-actions

github-actions Bot commented Jul 10, 2026 •

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-10-16T16:45:27.301Z

Summary

Metric Count
Packages analyzed 20
Packages with changes 1
🔴 Breaking changes 0
🟡 Non-breaking changes 9
🟢 Additions 2

🤖 This report was reviewed by claude-sonnet-4-6.


@clerk/shared

Current version: 4.40.0
Recommended bump: MINOR → 4.41.0

Subpath ./internal/clerk-js/errors

🟢 Additions (1)

Added: clerkInvalidVerificationLevel
+ declare function clerkInvalidVerificationLevel(functionName: string, level: string): never;

Added function clerkInvalidVerificationLevel

Subpath ./types

🟡 Non-breaking Changes (9)

Modified: __internal_LocalizationResource
Diff (before: 2428 lines, after: 2437 lines). Click to expand.
// ... 502 unchanged lines elided ...
        title: LocalizationValue;
        subtitle: LocalizationValue;
      };
+     passkeyMfa: {
+       title: LocalizationValue;
+       subtitle: LocalizationValue;
+     };
      alternativeMethods: {
        title: LocalizationValue;
        subtitle: LocalizationValue;
        actionLink: LocalizationValue;
        actionText: LocalizationValue;
        blockButton__emailLink: LocalizationValue<'identifier'>;
        blockButton__emailCode: LocalizationValue<'identifier'>;
        blockButton__phoneCode: LocalizationValue<'identifier'>;
        blockButton__password: LocalizationValue;
        blockButton__passkey: LocalizationValue;
        blockButton__totp: LocalizationValue;
        blockButton__backupCode: LocalizationValue;
        getHelp: {
          title: LocalizationValue;
          content: LocalizationValue;
          blockButton__emailSupport: LocalizationValue;
        };
      };
      noAvailableMethods: {
        title: LocalizationValue;
        subtitle: LocalizationValue;
        message: LocalizationValue;
      };
      accountSwitcher: {
        title: LocalizationValue;
        subtitle: LocalizationValue;
        action__addAccount: LocalizationValue;
        action__signOutAll: LocalizationValue;
      };
      enterpriseConnections: {
        title: LocalizationValue;
        subtitle: LocalizationValue;
      };
      enterpriseSSO: {
        formButtonPrimary: LocalizationValue;
      };
      ssoBypass: {
        actionLink: LocalizationValue;
        notice: LocalizationValue;
        code: {
          title: LocalizationValue;
          subtitle: LocalizationValue;
          resendButton: LocalizationValue;
        };
      };
      web3Solana: {
        title: LocalizationValue;
        subtitle: LocalizationValue;
      };
      protectCheck: {
        title: LocalizationValue;
        subtitle: LocalizationValue;
        loading: LocalizationValue;
        retryButton: LocalizationValue;
      };
    };
    reverification: {
      password: {
        title: LocalizationValue;
        subtitle: LocalizationValue;
        actionLink: LocalizationValue;
      };
      emailCode: {
        title: LocalizationValue;
        subtitle: LocalizationValue;
        formTitle: LocalizationValue;
        resendButton: LocalizationValue;
      };
      phoneCode: {
        title: LocalizationValue;
        subtitle: LocalizationValue;
        formTitle: LocalizationValue;
        resendButton: LocalizationValue;
      };
      phoneCodeMfa: {
        title: LocalizationValue;
        subtitle: LocalizationValue;
        formTitle: LocalizationValue;
        resendButton: LocalizationValue;
      };
      totpMfa: {
        title: LocalizationValue;
        subtitle: LocalizationValue;
        formTitle: LocalizationValue;
      };
      backupCodeMfa: {
        title: LocalizationValue;
        subtitle: LocalizationValue;
      };
      passkey: {
+       title: LocalizationValue;
+       subtitle: LocalizationValue;
+       blockButton__passkey: LocalizationValue;
+     };
+     passkeyMfa: {
        title: LocalizationValue;
        subtitle: LocalizationValue;
        blockButton__passkey: LocalizationValue;
// ... 1830 unchanged lines elided ...

Static analyzer: Breaking change in type alias __internal_LocalizationResource: Type changed: {locale:string;maintenanceMode:import("@clerk/shared").LocalizationValue;roles:{[r:string]:import("@clerk/shared").Loca… → {locale:string;maintenanceMode:import("@clerk/shared").LocalizationValue;roles:{[r:string]:import("@clerk/shared").Loca…

🤖 AI review (reclassified as non-breaking) (70%): __internal_LocalizationResource is used only as the base of LocalizationResource (a DeepPartial extension), and the change appears to be adding new localization keys (the elided section grew by ~9 lines); adding optional keys to an output/extension type does not break existing consumers who supply partial overrides.

Modified: AttemptSecondFactorParams
- type AttemptSecondFactorParams = PhoneCodeAttempt | TOTPAttempt | BackupCodeAttempt | EmailCodeAttempt;
+ type AttemptSecondFactorParams = PhoneCodeAttempt | TOTPAttempt | BackupCodeAttempt | EmailCodeAttempt | PasskeyAttempt;

Static analyzer: Breaking change in type alias AttemptSecondFactorParams: Type changed: import("@clerk/shared").BackupCodeAttempt|import("@clerk/shared").EmailCodeAttempt|import("@clerk/shared").PhoneCodeAtt… → import("@clerk/shared").BackupCodeAttempt|import("@clerk/shared").EmailCodeAttempt|import("@clerk/shared").PasskeyAttem…

🤖 AI review (reclassified as non-breaking) (90%): AttemptSecondFactorParams is a parameter type (input position) of attemptSecondFactor; adding PasskeyAttempt to the union widens the set of accepted values, which is safe for callers who pass one of the previously valid variants.

Modified: PrepareSecondFactorParams
- type PrepareSecondFactorParams = PhoneCodeSecondFactorConfig | EmailCodeSecondFactorConfig | EmailLinkConfig;
+ type PrepareSecondFactorParams = PhoneCodeSecondFactorConfig | EmailCodeSecondFactorConfig | EmailLinkConfig | PassKeyConfig;

Static analyzer: Breaking change in type alias PrepareSecondFactorParams: Type changed: import("@clerk/shared").EmailCodeSecondFactorConfig|import("@clerk/shared").EmailLinkConfig|import("@clerk/shared").Pho… → import("@clerk/shared").EmailCodeSecondFactorConfig|import("@clerk/shared").EmailLinkConfig|import("@clerk/shared").Pas…

🤖 AI review (reclassified as non-breaking) (90%): PrepareSecondFactorParams is a parameter type (input position) of prepareSecondFactor; adding PassKeyConfig to the union widens the accepted input, which is non-breaking for callers passing any previously valid variant.

Modified: SessionResource.verifyWithPasskey
- verifyWithPasskey: () => Promise<SessionVerificationResource>;
+ verifyWithPasskey: (params?: SessionVerifyWithPasskeyParams) => Promise<SessionVerificationResource>;

Static analyzer: Breaking change in property SessionResource.verifyWithPasskey: Type changed: ()=>!Promise:interface<import("@clerk/shared").SessionVerificationResource> → (params?:import("@clerk/shared").SessionVerifyWithPasskeyParams)=>!Promise:interface<import("@clerk/shared").SessionVer…

🤖 AI review (reclassified as non-breaking) (90%): The new signature adds an optional parameter (params?: SessionVerifyWithPasskeyParams) to verifyWithPasskey; callers that previously called it with no arguments continue to compile and behave correctly since the parameter is optional.

Modified: SessionVerificationSecondFactor
- type SessionVerificationSecondFactor = PhoneCodeFactor | TOTPFactor | BackupCodeFactor;
+ type SessionVerificationSecondFactor = PhoneCodeFactor | TOTPFactor | BackupCodeFactor | PasskeyFactor;

Static analyzer: Breaking change in type alias SessionVerificationSecondFactor: Type changed: import("@clerk/shared").BackupCodeFactor|import("@clerk/shared").PhoneCodeFactor|import("@clerk/shared").TOTPFactor → import("@clerk/shared").BackupCodeFactor|import("@clerk/shared").PasskeyFactor|import("@clerk/shared").PhoneCodeFactor|…

🤖 AI review (reclassified as non-breaking) (90%): SessionVerificationSecondFactor is used as the element type of the output field supportedSecondFactors; adding PasskeyFactor to the union widens the output type, which is safe for consumers who only read the array and switch on the existing variants.

Modified: SessionVerifyAttemptSecondFactorParams
- type SessionVerifyAttemptSecondFactorParams = PhoneCodeAttempt | TOTPAttempt | BackupCodeAttempt
+ type SessionVerifyAttemptSecondFactorParams = PhoneCodeAttempt | TOTPAttempt | BackupCodeAttempt | PasskeyAttempt
  /**
   * @experimental
   */
// ... 1 unchanged line elided ...

Static analyzer: Breaking change in type alias SessionVerifyAttemptSecondFactorParams: Type changed: import("@clerk/shared").BackupCodeAttempt /** * @experimental */|import("@clerk/shared").PhoneCodeAttempt|import("@cler… → import("@clerk/shared").BackupCodeAttempt|import("@clerk/shared").PasskeyAttempt /** * @experimental */|import("@clerk/…

🤖 AI review (reclassified as non-breaking) (90%): SessionVerifyAttemptSecondFactorParams is a parameter type (input position) of attemptSecondFactorVerification; adding PasskeyAttempt to the union widens the accepted input, which is non-breaking for callers passing any previously valid variant.

Modified: SessionVerifyPrepareSecondFactorParams
- type SessionVerifyPrepareSecondFactorParams = PhoneCodeSecondFactorConfig
+ type SessionVerifyPrepareSecondFactorParams = PhoneCodeSecondFactorConfig | PassKeyConfig
  /**
   * @experimental
   */
// ... 1 unchanged line elided ...

Static analyzer: Breaking change in type alias SessionVerifyPrepareSecondFactorParams: Type changed: import("@clerk/shared").PhoneCodeSecondFactorConfig /** * @experimental */|import("@clerk/shared").TrustedDeviceConfig → import("@clerk/shared").PassKeyConfig /** * @experimental */|import("@clerk/shared").PhoneCodeSecondFactorConfig|import…

🤖 AI review (reclassified as non-breaking) (90%): SessionVerifyPrepareSecondFactorParams is a parameter type (input position) of prepareSecondFactorVerification; adding PassKeyConfig to the union widens the accepted input, which is non-breaking for callers passing any previously valid variant.

Modified: SignInFutureResource.mfa
// ... 16 unchanged lines elided ...
      verifyBackupCode: (params: SignInFutureBackupCodeVerifyParams) => Promise<{
        error: ClerkError | null;
      }>;
+     passkey: () => Promise<{
+       error: ClerkError | null;
+     }>;
    };

Static analyzer: Breaking change in property SignInFutureResource.mfa: Type changed: {sendPhoneCode:()=>!Promise:interface<{error:import("@clerk/shared").~ClerkError|null;}>;verifyPhoneCode:(params:import… → {sendPhoneCode:()=>!Promise:interface<{error:import("@clerk/shared").~ClerkError|null;}>;verifyPhoneCode:(params:import…

🤖 AI review (reclassified as non-breaking) (85%): The change adds a new passkey method to the mfa object on SignInFutureResource, which is an output/return type; existing consumers reading the object are unaffected by the addition of a new optional method they don't reference.

Modified: SignInSecondFactor
- type SignInSecondFactor = PhoneCodeFactor | TOTPFactor | BackupCodeFactor | EmailCodeFactor | EmailLinkFactor;
+ type SignInSecondFactor = PhoneCodeFactor | TOTPFactor | BackupCodeFactor | EmailCodeFactor | EmailLinkFactor | PasskeyFactor;

Static analyzer: Breaking change in type alias SignInSecondFactor: Type changed: import("@clerk/shared").BackupCodeFactor|import("@clerk/shared").EmailCodeFactor|import("@clerk/shared").EmailLinkFacto… → import("@clerk/shared").BackupCodeFactor|import("@clerk/shared").EmailCodeFactor|import("@clerk/shared").EmailLinkFacto…

🤖 AI review (reclassified as non-breaking) (85%): SignInSecondFactor is used as an output field element type (supportedSecondFactors) and in SignInSecondFactorJSON derivation; adding PasskeyFactor widens the union in an output position, which is safe for consumers reading and switching on the existing variants.

🟢 Additions (1)

Added: SessionVerifyWithPasskeyParams
+ type SessionVerifyWithPasskeyParams = {
+   level?: 'first_factor' | 'second_factor';
+ };

Added type alias SessionVerifyWithPasskeyParams


Report generated by Break Check

Last ran on 5d55d7a.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
packages/ui/src/components/SignIn/__tests__/SignInFactorTwo.test.tsx (1)

407-424: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Test may pass for the wrong reason — wrap in mockWebAuthn to test priority ordering.

Without mockWebAuthn, isWebAuthnSupported() likely returns false in the test environment, so passkey is excluded from candidacy regardless of priority sorting against TOTP. This test currently can't distinguish "TOTP wins on priority" from "passkey is filtered out due to no WebAuthn support." Wrapping this in mockWebAuthn(() => { ... }) (as done for the sibling tests below) would ensure the sorting/priority logic is actually exercised.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/SignIn/__tests__/SignInFactorTwo.test.tsx` around
lines 407 - 424, Update the Passkey test “is never the starting factor when
another second factor is available” to wrap its fixture setup, render, and
assertions in mockWebAuthn, matching the sibling tests below. Keep supportTotp
and supportPasskey enabled so the test validates priority ordering rather than
passkey being excluded by unsupported WebAuthn.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts`:
- Around line 261-264: Restore direct static assignments in the passkey test
suites: update the authenticateWithPasskey and mfa.passkey cleanup to save the
original BaseResource._fetch and SignIn.clerk values before stubbing, then
reassign those originals in afterEach alongside vi.clearAllMocks() and
vi.unstubAllGlobals().

In `@packages/clerk-js/src/core/resources/Session.ts`:
- Around line 320-329: The verifyWithPasskey method silently treats invalid
levels as first-factor verification. Default level with nullish coalescing, then
explicitly validate that it is either 'first_factor' or 'second_factor' and
reject any other value before calling prepareFirstFactorVerification or
prepareSecondFactorVerification.
- Around line 320-329: Document the public verifyWithPasskey API with JSDoc
directly above the verifyWithPasskey method in Session, describing the
params.level options and default, the returned SessionVerificationResource, and
possible error behavior; flag the contract change for Docs-team review or
accompanying generated documentation updates.

In `@packages/clerk-js/src/core/resources/SignIn.ts`:
- Around line 575-610: Add or update JSDoc for the public
authenticateWithPasskey method to document that needs_second_factor and
needs_client_trust sign-ins reuse the in-progress sign-in via the second-factor
flow, and that the flow option is ignored in those states. Include the API
behavior change clearly and mark the documentation for Docs-team review.

In `@packages/ui/src/components/UserVerification/UVFactorTwoPasskeyCard.tsx`:
- Around line 32-41: Update handlePasskeysAttempt to guard against concurrent
passkey attempts: set the card’s loading state before calling verifyWithPasskey,
clear it in a finally handler regardless of success or failure, and disable the
passkey button while loading so additional clicks cannot abort the in-flight
ceremony.

---

Nitpick comments:
In `@packages/ui/src/components/SignIn/__tests__/SignInFactorTwo.test.tsx`:
- Around line 407-424: Update the Passkey test “is never the starting factor
when another second factor is available” to wrap its fixture setup, render, and
assertions in mockWebAuthn, matching the sibling tests below. Keep supportTotp
and supportPasskey enabled so the test validates priority ordering rather than
passkey being excluded by unsupported WebAuthn.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: c030f53b-d9cd-46b4-8a0f-e7f81cb887a2

📥 Commits

Reviewing files that changed from the base of the PR and between 7b581aa and 707e4ad.

📒 Files selected for processing (73)
  • .changeset/passkey-second-factor.md
  • packages/clerk-js/src/core/resources/Session.ts
  • packages/clerk-js/src/core/resources/SignIn.ts
  • packages/clerk-js/src/core/resources/__tests__/Session.test.ts
  • packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
  • packages/localizations/src/ar-SA.ts
  • packages/localizations/src/be-BY.ts
  • packages/localizations/src/bg-BG.ts
  • packages/localizations/src/bn-IN.ts
  • packages/localizations/src/ca-ES.ts
  • packages/localizations/src/cs-CZ.ts
  • packages/localizations/src/da-DK.ts
  • packages/localizations/src/de-DE.ts
  • packages/localizations/src/el-GR.ts
  • packages/localizations/src/en-GB.ts
  • packages/localizations/src/en-US.ts
  • packages/localizations/src/es-CR.ts
  • packages/localizations/src/es-ES.ts
  • packages/localizations/src/es-MX.ts
  • packages/localizations/src/es-UY.ts
  • packages/localizations/src/fa-IR.ts
  • packages/localizations/src/fi-FI.ts
  • packages/localizations/src/fr-FR.ts
  • packages/localizations/src/he-IL.ts
  • packages/localizations/src/hi-IN.ts
  • packages/localizations/src/hr-HR.ts
  • packages/localizations/src/hu-HU.ts
  • packages/localizations/src/id-ID.ts
  • packages/localizations/src/is-IS.ts
  • packages/localizations/src/it-IT.ts
  • packages/localizations/src/ja-JP.ts
  • packages/localizations/src/kk-KZ.ts
  • packages/localizations/src/ko-KR.ts
  • packages/localizations/src/mn-MN.ts
  • packages/localizations/src/ms-MY.ts
  • packages/localizations/src/nb-NO.ts
  • packages/localizations/src/nl-BE.ts
  • packages/localizations/src/nl-NL.ts
  • packages/localizations/src/pl-PL.ts
  • packages/localizations/src/pt-BR.ts
  • packages/localizations/src/pt-PT.ts
  • packages/localizations/src/ro-RO.ts
  • packages/localizations/src/ru-RU.ts
  • packages/localizations/src/sk-SK.ts
  • packages/localizations/src/sr-RS.ts
  • packages/localizations/src/sv-SE.ts
  • packages/localizations/src/ta-IN.ts
  • packages/localizations/src/te-IN.ts
  • packages/localizations/src/th-TH.ts
  • packages/localizations/src/tr-TR.ts
  • packages/localizations/src/uk-UA.ts
  • packages/localizations/src/vi-VN.ts
  • packages/localizations/src/zh-CN.ts
  • packages/localizations/src/zh-TW.ts
  • packages/shared/src/internal/clerk-js/errors.ts
  • packages/shared/src/types/localization.ts
  • packages/shared/src/types/session.ts
  • packages/shared/src/types/sessionVerification.ts
  • packages/shared/src/types/signInCommon.ts
  • packages/shared/src/types/signInFuture.ts
  • packages/ui/src/components/SignIn/SignInClientTrust.tsx
  • packages/ui/src/components/SignIn/SignInFactorTwo.tsx
  • packages/ui/src/components/SignIn/SignInFactorTwoAlternativeMethods.tsx
  • packages/ui/src/components/SignIn/SignInFactorTwoPasskeyCard.tsx
  • packages/ui/src/components/SignIn/__tests__/SignInFactorTwo.test.tsx
  • packages/ui/src/components/SignIn/shared.ts
  • packages/ui/src/components/SignIn/utils.ts
  • packages/ui/src/components/UserVerification/UVFactorTwoAlternativeMethods.tsx
  • packages/ui/src/components/UserVerification/UVFactorTwoPasskeyCard.tsx
  • packages/ui/src/components/UserVerification/UserVerificationFactorTwo.tsx
  • packages/ui/src/components/UserVerification/__tests__/UVFactorTwo.test.tsx
  • packages/ui/src/test/fixture-helpers.ts
  • packages/ui/src/utils/factorSorting.ts

Comment thread packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
Comment thread packages/clerk-js/src/core/resources/Session.ts
Comment thread packages/clerk-js/src/core/resources/SignIn.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/shared/src/internal/clerk-js/errors.ts (1)

105-112: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the exported error helper.

The new exported function has an empty JSDoc block. Add accurate @param, @returns, @throws, and @example documentation describing the supported verification levels and caller-facing error. If this export is reference-facing, please also have the Docs team review the generated API content.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/internal/clerk-js/errors.ts` around lines 105 - 112,
Document the exported clerkInvalidVerificationLevel helper with complete JSDoc:
describe functionName and level via `@param`, state the never return behavior via
`@returns`, document the thrown caller-facing error via `@throws`, and add an
`@example` showing an invalid level and the supported first_factor and
second_factor values. Keep the documentation aligned with the existing error
message and request Docs review for the generated API content if this export is
reference-facing.

Sources: Coding guidelines, Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/shared/src/internal/clerk-js/errors.ts`:
- Around line 105-112: Document the exported clerkInvalidVerificationLevel
helper with complete JSDoc: describe functionName and level via `@param`, state
the never return behavior via `@returns`, document the thrown caller-facing error
via `@throws`, and add an `@example` showing an invalid level and the supported
first_factor and second_factor values. Keep the documentation aligned with the
existing error message and request Docs review for the generated API content if
this export is reference-facing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 9bc817b9-9d9f-4562-931c-26016589d189

📥 Commits

Reviewing files that changed from the base of the PR and between 707e4ad and c23d0c4.

📒 Files selected for processing (9)
  • .changeset/passkey-second-factor.md
  • packages/clerk-js/src/core/resources/Session.ts
  • packages/clerk-js/src/core/resources/SignIn.ts
  • packages/clerk-js/src/core/resources/__tests__/Session.test.ts
  • packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
  • packages/react/src/stateProxy.ts
  • packages/shared/src/internal/clerk-js/errors.ts
  • packages/shared/src/types/session.ts
  • packages/ui/src/components/UserVerification/UVFactorTwoPasskeyCard.tsx
🚧 Files skipped from review as they are similar to previous changes (7)
  • .changeset/passkey-second-factor.md
  • packages/clerk-js/src/core/resources/tests/Session.test.ts
  • packages/ui/src/components/UserVerification/UVFactorTwoPasskeyCard.tsx
  • packages/clerk-js/src/core/resources/tests/SignIn.test.ts
  • packages/shared/src/types/session.ts
  • packages/clerk-js/src/core/resources/Session.ts
  • packages/clerk-js/src/core/resources/SignIn.ts

If the environment variable on the backend is unset, the backend will
not advertise passkeys for second factor. The javascript code, when on a
new clerk js version that does support passkeys as second factor, was
treating that as an error. Instead just fail gracefully, clients
shouldn't be disrupted if the backend doesn't yet advertise support for
something.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/clerk-js/src/core/resources/SignIn.ts (1)

594-619: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate second-factor passkey ceremony logic between authenticateWithPasskey and verifyMFAPasskey.

Both blocks implement the same sequence (prepare second factor with strategy: 'passkey' → read/parse nonce → convert to public-key options → fetch credential → submit attempt), but diverge in error handling (clerkMissingWebAuthnPublicKeyOptions/raw throw error here vs. ClerkRuntimeError/ClerkWebAuthnError there). Extracting a shared private helper would prevent the two paths from drifting further as passkey second-factor logic evolves.

Also applies to: 1563-1610

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/clerk-js/src/core/resources/SignIn.ts` around lines 594 - 619,
Extract the duplicated second-factor passkey ceremony from
authenticateWithPasskey and verifyMFAPasskey into a shared private helper on
SignIn. Have both callers use the helper, preserving the existing
prepareSecondFactor, nonce parsing, public-key option conversion, credential
retrieval, and attemptSecondFactor flow while standardizing their error handling
through the shared implementation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/clerk-js/src/core/resources/SignIn.ts`:
- Around line 594-619: Extract the duplicated second-factor passkey ceremony
from authenticateWithPasskey and verifyMFAPasskey into a shared private helper
on SignIn. Have both callers use the helper, preserving the existing
prepareSecondFactor, nonce parsing, public-key option conversion, credential
retrieval, and attemptSecondFactor flow while standardizing their error handling
through the shared implementation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 37a94a0a-db64-4faf-8786-49a7017aba5d

📥 Commits

Reviewing files that changed from the base of the PR and between c23d0c4 and 1e0fbd7.

📒 Files selected for processing (4)
  • .changeset/passkey-second-factor.md
  • packages/clerk-js/src/core/resources/SignIn.ts
  • packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
  • packages/shared/src/internal/clerk-js/errors.ts
💤 Files with no reviewable changes (1)
  • packages/shared/src/internal/clerk-js/errors.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • .changeset/passkey-second-factor.md

return localizationKeys('signIn.alternativeMethods.blockButton__passkey');
default:
((_: never) => _)(factor);
throw new Error('Invalid sign in strategy');

@Ephem Ephem Sep 28, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seems to be a case where this can throw and crash after this PR:

  • FAPI on 2026-08-20
  • Latest clerk-js
  • Pinned ui to older version
  • PasskeySettings.SatisfiesSecondFactor true
  • The user has a verified passkey and didn't use it as the first factor
  • Click on "Use another method"

When this happens, the older UI version wont recognize the factor and hit this throw.

I think we need to gate on both FAPI and UI version?

~ Found by 🤖, written up by 👨

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good point, thanks for pointing out the clerk-js/ui pinning split possibility. It looks like we've never had to (or never bothered to) anticipate this before, so implementing it, while of course possible, is a bit more of an involved change than I expected. I'll take a look.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think when we have hit this in the past we have usually been able to work around it by providing a backwards compatible API without looking specifically at the uiVersion. The ui package is still somewhat new so I don't think we've run into this too many times either (I just remember a React Query mismatch really).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I commented on the wrong comment, I implemented a guard here b104169. It's a bit complex since we haven't done exactly this before, but this was the simplest approach I could think of which is also safe for different loading orders (I believe).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Annoying as it is to have to handle, I think that seems like a reasonable approach. I'm trying to think through how we best handle this and I'm a bit stumped tbh. Do you have any thoughts here @nikosdouvlis?

Context is, a component in UI isn't handling unknown factors well (throwing) and we want to introduce a new one without breaking people who have pinned old UI versions.

Daniels approach tackles it by introducing a __internal_capabilities field on ui and rewiring things in clerk-js a bit to always be able to get a hold of that even when ui was provided and not hotloaded.

We should avoid relying on __internal_capabilities and instead build UI in a defensive enough way that it can handle additive features, so if we go this route I'd see it as an escape hatch rather than something to encourage.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was pondering this again today and I realized something that's tricky here. We are now changing clerk-js behavior regardless of who is using it depending on which UI version is loaded. So if you pin UI and also use custom flows, your custom flows are now surprisingly affected by the UI pin.

Also, say you aren't loading UI at all? Then there's no way to flag the capability and your custom flow would never get this behavior.

I don't think that matters too much for this specific case, I'm only bringing it up since we are establishing a new pattern here.

Because of how UI is currently built I only really see two approaches here, either something like this, or just accept this as a latent bug in UI. The second route would be clearly better if we weren't hotloading clerk-js, but since that means we are the ones triggering the bug it's trickier.

Sorry for the delay and back and forth here, it's just that you happen to be the first one to run into this. I'll try to get some more eyes on it.


// This is the currently supported version of the Frontend API
export const SUPPORTED_FAPI_VERSION = '2026-05-12';
export const SUPPORTED_FAPI_VERSION = '2026-08-20';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also means that C1s using custom flows without a pinned clerk-js version will get the new supportedSecondFactors.

In theory, someone could be doing the same thing as us and have an exhaustive check, or could be mapping over all factors and not just known ones they handle in their app, but I think that's unlikely.

If building custom flows, I think you should either pin clerk-js or be resilient to additive changes, so I don't see this as a problem but still wanted to mention it as something to be aware of.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that this is a more general point of company policy/culture. For better or for worse we add to API responses fairly often even on old API versions and don't treat it as a potential breaking change, although it can be if custom flows are used (or if our SDK is unexpectedly brittle). I think that we just have to accept this risk in cases like this; at least it's a new API version.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed! If this was a new case you had to handle in your custom flows that would be different.

Still good to think about every now and then though since there are trickier cases too. For example, if we add that "a separate '2FA required' flag" and now passkey might be the only supported 2FA, now custom flows might actually be in a dead end scenario if they don't handle it. Does that make it breaking even though it's in one sense purely additive?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a guard here: b104169

There's definitely some complexity here and I'd appreciate your eyes on it. I tried to safely handle cases where we can't predict the order in which things will be loaded.

It's a bit of a bespoke implementation, but I couldn't come up with a reasonable entirely general approach.

Comment thread .changeset/passkey-second-factor.md Outdated
Comment thread .changeset/passkey-second-factor.md Outdated
…key-as-2fa-option

# Conflicts:
#	packages/clerk-js/bundlewatch.config.json
Support guarding capabilities for `ui` when `ui` and `clerk-js` version
may diverge. `clerk-js` should only include second factor passkey when
it's included in the API response and then `ui` lists it as supported.
dmoerner and others added 3 commits October 15, 2026 17:13
This reverts commit b104169.

The backend now gates passkey in supported_second_factors for browser
clients behind a per-instance opt-in, so clerk-js no longer needs to
filter it based on the loaded UI's declared capabilities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Prebuilt sign-in and reverification flows decided whether to preselect or
offer a passkey with the plain browser check, ignoring
`clerk.__internal_isWebAuthnSupported`. SDKs such as Electron set that
override when passkeys can't work even though `PublicKeyCredential`
exists, so those flows could open on a passkey card that always fails.

Route the passkey checks for the starting first and second factor, and
for the second-factor alternatives, through a new `useIsWebAuthnSupported`
hook, matching what `SignInStart` already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…key-as-2fa-option

# Conflicts:
#	integration/tests/passkeys.test.ts
#	packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
#	packages/shared/src/types/session.ts
…udgets

Passkey second-factor support in SignIn and Session pushes both bundles just
over their gzipped budgets (82.02KB > 82KB, 322.45KB > 322.25KB). Raise each
by 0.5KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dmoerner

Copy link
Copy Markdown
Contributor Author

Updates on this branch:

  • 3241352: Revert the capabilities guard discussed above for pinned ui, we are now handling this with a Dashboard update for old instances.
  • 8900534: Electron fix to not show passkeys when it's unavailable. Caught this when I was trying to look at all the different possible upgrade inconsistencies.

…tor-two step

Calling `signIn.mfa.passkey()` twice in quick succession sends two
prepare_second_factor requests, so two passkey challenges are issued and
the first ceremony's attempt fails against a replaced challenge. The
prepare now coalesces, like the other MFA prepares.

When passkey is the only second factor offered and the browser doesn't
support WebAuthn, the prebuilt factor-two step shows a loading spinner
that never resolves, leaving the user stuck. The starting factor now falls
back to the passkey, so the passkey card renders and surfaces the
unsupported error instead.

Part of USER-3966

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The passkey second-factor suite fails on every run: enrolling the test
user in TOTP is rejected with form_param_unknown (totp_secret), because
the with-passkeys instance, now created from a Platform API config, does
not enable the authenticator app. The suite also relies on passkeys being
allowed to satisfy the second factor, which the config left to the
instance default.

Enable the authenticator app and set satisfies_second_factor explicitly.

Part of USER-3966

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

2 active deployments
Preview – swingset — 5d55d7a9 Deployed Oct 16, 2026 by vercel[bot]
Preview – clerk-js-sandbox — 5d55d7a9 Deployed Oct 16, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants