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
- Instance config: enable passkeys as a first factor, enable Allow autofill, enable Google OAuth, user has TOTP as second factor.
- Use
<SignIn /> with path routing (e.g. Next.js catch-all at /signin).
- Sign out, go to
/signin, sign in with Google (account with TOTP enabled).
- On the factor-two screen, enter a valid TOTP code.
- 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.
Preliminary checks
signIn.reset()/client.resetSignIn()don't propagate a fresh empty SignIn to the FutureuseSignIn()hook, sosso()after reset reuses the stale resource and never navigates #9006, which is aboutresetSignIn()propagation — a different bug)@clerk/clerk-js6.25.2 (hotloaded),@clerk/ui1.25.2,@clerk/nextjs7.5.17; the relevant code is unchanged in@clerk/ui1.26.0Summary
When an instance has passkeys enabled as a first factor and
passkey_settings.allow_autofill: true, the prebuiltSignInStartcomponent silently destroys an in-progress sign-in attempt that is already atneeds_second_factor. The user sees the factor-two TOTP form, enters a correct code, and gets: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.
SignInStartruns passkey autofill unconditionally on every mount (useAutoFillPasskey,src/components/SignIn/SignInStart.tsx):There is no check of
client.signIn.status— the effect fires even when the client already holds a valid attempt atneeds_second_factor.2.
authenticateWithPasskey({ flow: "autofill" })starts by replacing the attempt:SignIn.createreplaces 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 — thecreatePOST has already been sent and cannot be undone.Why this hits the OAuth + MFA flow
After an OAuth redirect,
handleRedirectCallbackroutesneeds_second_factorthrough the default hash-route URL ({signInUrl}#/factor-two). That intermediate navigation briefly mountsSignInStartbefore the hash is converted to the factor-two path. The autofill effect fires during that brief mount; thecreatePOST is still in flight whenSignInFactorTwomounts (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 submitsattempt_second_factoragainst aneeds_identifierpasskey attempt → 422.Production timeline (from our analytics, reproduced identically on every attempt):
/signin/create/sso-callback— valid Google attempt,needs_second_factor/signin+#/factor-two(default hash-route hop) —SignInStartmounts, autofill firesPOST /v1/client/sign_ins/signin/factor-two— TOTP form renders against the (still valid) OAuth attemptclient.signInis nowneeds_identifier/ passkey / unverifiedSteps to reproduce
<SignIn />with path routing (e.g. Next.js catch-all at/signin)./signin, sign in with Google (account with TOTP enabled).window.Clerk.client.signInwhile the TOTP form is displayed showsstatus: "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 whereverPublicKeyCredential.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 whenclient.signIn.statusis alreadyneeds_second_factor(or more generally when an attempt exists and has progressed past identifier/first-factor). Alternatively, make the autofill/discoverable branch ofauthenticateWithPasskeyrefuse tocreateover 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.