Skip to content

SignInStart passkey autofill replaces an in-progress needs_second_factor sign-in attempt, breaking OAuth + MFA #9265

Description

@ianwatts22

Preliminary checks

Summary

When an instance has passkeys enabled as a first factor and passkey_settings.allow_autofill: true, the prebuilt SignInStart component silently destroys an in-progress sign-in attempt that is already at needs_second_factor. The user sees the factor-two TOTP form, enters a correct code, and gets:

This Sign In Attempt is not ready for factor two verification.

This makes OAuth sign-in + MFA completely unusable for affected users — the primary sign-in path dead-ends at the second factor every time.

Root cause

Two pieces compose into the bug:

1. SignInStart runs passkey autofill unconditionally on every mount (useAutoFillPasskey, src/components/SignIn/SignInStart.tsx):

useEffect(() => {
  async function runAutofillPasskey() {
    const _isSupported = await isWebAuthnAutofillSupported();
    if (!_isSupported) return;
    await authenticateWithPasskey({ flow: "autofill" });
  }
  if (passkeySettings.allow_autofill && attributes.passkey?.enabled) runAutofillPasskey();
}, []);

There is no check of client.signIn.status — the effect fires even when the client already holds a valid attempt at needs_second_factor.

2. authenticateWithPasskey({ flow: "autofill" }) starts by replacing the attempt:

if ("autofill" === flow || "discoverable" === flow) await this.create({ strategy: "passkey" });

SignIn.create replaces the client's single active sign-in attempt with a fresh passkey attempt (status: needs_identifier, firstFactorVerification: { strategy: "passkey", status: "unverified" }). The __internal_WebAuthnAbortService.abort() cleanup on unmount only cancels the conditional-UI credential request — the create POST has already been sent and cannot be undone.

Why this hits the OAuth + MFA flow

After an OAuth redirect, handleRedirectCallback routes needs_second_factor through the default hash-route URL ({signInUrl}#/factor-two). That intermediate navigation briefly mounts SignInStart before the hash is converted to the factor-two path. The autofill effect fires during that brief mount; the create POST is still in flight when SignInFactorTwo mounts (client-side navigation, same document), and when its response lands ~300–400ms later it swaps the valid OAuth attempt out from under the rendered TOTP form. The form then submits attempt_second_factor against a needs_identifier passkey attempt → 422.

Production timeline (from our analytics, reproduced identically on every attempt):

t event
0ms /signin/create/sso-callback — valid Google attempt, needs_second_factor
+640ms /signin + #/factor-two (default hash-route hop) — SignInStart mounts, autofill fires POST /v1/client/sign_ins
+1080ms /signin/factor-two — TOTP form renders against the (still valid) OAuth attempt
+1460ms passkey-create response lands → client.signIn is now needs_identifier / passkey / unverified
user submits TOTP 422 "This Sign In Attempt is not ready for factor two verification"

Steps to reproduce

  1. Instance config: enable passkeys as a first factor, enable Allow autofill, enable Google OAuth, user has TOTP as second factor.
  2. Use <SignIn /> with path routing (e.g. Next.js catch-all at /signin).
  3. Sign out, go to /signin, sign in with Google (account with TOTP enabled).
  4. On the factor-two screen, enter a valid TOTP code.
  5. Observe the 422 error. Inspecting window.Clerk.client.signIn while the TOTP form is displayed shows status: "needs_identifier", firstFactorVerification.strategy: "passkey".

Browser note

We observed this in production in Safari 18.5/27.0, but the mechanism is browser-independent — the stray SignIn.create({strategy:"passkey"}) fires wherever PublicKeyCredential.isConditionalMediationAvailable() resolves true (Chrome/Safari/Edge), before any credential UI appears. Our Chrome traffic simply had no fresh MFA sign-ins during the affected window, so don't read a Safari dependency into it.

Expected behavior

Mounting SignInStart (including transient mounts during redirect-callback routing) must not destroy an existing sign-in attempt that has progressed past the first factor.

Suggested fix

In useAutoFillPasskey, skip the autofill flow when client.signIn.status is already needs_second_factor (or more generally when an attempt exists and has progressed past identifier/first-factor). Alternatively, make the autofill/discoverable branch of authenticateWithPasskey refuse to create over an attempt that is past first factor.

Workaround (confirmed)

Disabling Allow autofill in the dashboard immediately fixes the flow (verified in production; the same OAuth+TOTP sign-in completes in seconds with no other changes). Any team combining passkey autofill + social OAuth + MFA on the prebuilt components will hit this.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions