#12759·zitadel

[Bug]: Login V2 re-enables Continue while automatic passkey WebAuthn request is still pending

Author: matiasracedoCreated Sep 16, 2026Updated Sep 16, 2026

Preflight Checklist

  • I could not find a solution in the documentation, the existing issues or discussions
  • I have joined the ZITADEL chat

Environment

ZITADEL Cloud

Version

4.17.3

Database

None

Database Version

No response

Describe the problem caused by this bug

When the Login V2 passkey page automatically starts WebAuthn authentication, the Continue button can become enabled while navigator.credentials.get() is still pending and the native iOS passkey UI is being presented.

If the user taps Continue before completing or dismissing the native passkey prompt, Login V2 can start another authentication attempt. This may result in:

  • a second WebAuthn challenge being created for the same session;
  • another navigator.credentials.get() being invoked while the first ceremony is still outstanding;
  • duplicate/native passkey prompts;
  • AbortError, NotAllowedError, or InvalidStateError errors depending on the browser state;
  • or the first assertion failing because the session challenge was replaced while the original WebAuthn ceremony was still using it.

This is particularly visible on iOS/WebKit, where the native passkey UI does not guarantee that the underlying DOM cannot receive interaction while the WebAuthn promise is pending.

Probable cause

The issue appears to be in:

apps/login/src/components/login-passkey.tsx

The component uses a single loading state to disable the Continue button:

typescript
<Button
  type="submit"
  disabled={loading}
  ...
>

The automatic passkey flow starts with:

typescript
setLoading(true);

updateOrCreateSessionForChallenge()
  .then((response) => {
    ...
    return submitLoginAndContinue(pK);
  })

However, updateOrCreateSessionForChallenge() clears loading in its own finally():

typescript
const sessionResponse = await updateOrCreateSession(...)
  ...
  .finally(() => {
    setLoading(false);
  });

As a result, after the challenge has been obtained but before/during:

typescript
navigator.credentials.get({ publicKey })

the component has already returned to loading === false.

submitLoginAndContinue() does not set loading back to true before invoking navigator.credentials.get().

Interestingly, the explicit Continue-button path does call:

typescript
setLoading(true);
return submitLoginAndContinue(pK);

before WebAuthn, so the automatic and manual flows have different loading behavior.

Additional lifecycle issue

The current WebAuthn call does not pass an AbortSignal:

typescript
navigator.credentials.get({
  publicKey,
})

The Use Password action also navigates directly using router.push() without aborting an outstanding WebAuthn operation.

This means an active credential request can continue while Login V2 is navigating to another authentication method.

To reproduce

  1. Use Login V2 with a user that can authenticate using a passkey.
  2. Reach /ui/v2/login/passkey so that Login V2 automatically starts the passkey authentication flow.
  3. Reproduce on iOS using WebKit/Safari or an embedded WebKit authentication context.
  4. While the native passkey authentication UI is being presented/pending, observe that Continue becomes enabled.
  5. Tap Continue before completing the first passkey request.
  6. Observe that another authentication action can be dispatched while the original WebAuthn ceremony is still outstanding.
  7. Depending on timing, observe duplicate native prompts, a WebAuthn exception, or authentication failure.

Screenshots

No response

Expected behavior

There should be at most one passkey authentication ceremony in flight for the current session/challenge.

From the point where the WebAuthn challenge is requested until navigator.credentials.get() and server-side assertion verification have completed or been explicitly cancelled:

  • Continue should remain disabled;
  • another challenge must not be created;
  • another navigator.credentials.get() must not be started.

If the user explicitly selects an alternative authentication method such as Use Password, the current WebAuthn request should be cancelled before navigation.

Operating System

No response

Relevant Configuration

No response

Additional Context

Environment

ZITADEL Login V2

Observed in an iOS WebKit-based authentication flow / embedded web context when using passkey authentication.

The same flow does not reproduce as readily on Android/Chromium.

Suggested fix

Manage the complete passkey ceremony as one serialized frontend operation rather than letting nested helpers independently toggle loading.

For example:

  1. Set a passkeyPending / authentication phase before requesting the challenge.
  2. Keep it active while navigator.credentials.get() is pending.
  3. Keep it active while the assertion is being verified.
  4. Clear it only once the complete ceremony has failed, been cancelled, or completed.
  5. Add a synchronous useRef in-flight guard so two handlers cannot start ceremonies before React has committed a state update.
  6. Create an AbortController per ceremony and pass its signal to:
typescript
navigator.credentials.get({
  publicKey,
  signal: controller.signal,
})
  1. Abort the active controller before switching authentication methods or unmounting/navigating away.
  2. Use an attempt/generation ID to ignore stale WebAuthn completions that race with cancellation.

Suggested regression tests

Mock navigator.credentials.get() with an unresolved promise and verify that:

  • the automatic passkey flow keeps Continue disabled for the entire time the WebAuthn promise is pending;
  • clicking Continue cannot request another WebAuthn challenge while a ceremony is active;
  • navigator.credentials.get() can only be called once at a time;
  • selecting Use Password aborts the outstanding AbortSignal before navigation;
  • rejecting the WebAuthn promise with NotAllowedError returns the UI to a usable state.

Related

  • #12495 — Login V2 passkey page re-mints the WebAuthN challenge after load, causing the first assertion to use a stale challenge.
  • #10991 — Fixed promise chaining in the automatic passkey prompt flow.
  • #12505 — Safari-specific Login V2 WebAuthn/navigation behavior.

#12495 appears related at the invariant level — an outstanding WebAuthn ceremony must not have its challenge replaced — but this issue describes a separate race where an enabled Continue button can explicitly start a second challenge/ceremony while the first one is still pending.