From 0e9a012c41da00d9c62a98364f4d210c79303dce Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Thu, 10 Sep 2026 19:21:02 -0300 Subject: [PATCH 1/2] test(clerk-js): cover repeated passkey autofill calls Add tests that call the passkey flow twice on one sign-in. They assert a single sign-in creation while the first challenge is still pending, and a second creation once that challenge expired. Both fail before the fix. --- .../core/resources/__tests__/SignIn.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index df1d5a34891..f7bd6d7d7e3 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -1855,6 +1855,90 @@ describe('SignIn', () => { expect(mockWebAuthnGetCredential).toHaveBeenCalled(); }); + describe('pending passkey challenge', () => { + const credential = { + id: 'credential_123', + rawId: new ArrayBuffer(32), + response: { + authenticatorData: new ArrayBuffer(37), + clientDataJSON: new ArrayBuffer(121), + signature: new ArrayBuffer(64), + userHandle: null, + }, + type: 'public-key', + }; + + const createResponse = (expireAt: number) => ({ + client: null, + response: { + id: 'signin_123', + first_factor_verification: { + strategy: 'passkey', + status: 'unverified', + nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }), + expire_at: expireAt, + }, + }, + }); + + const isCreate = (call: any[]) => + call[0].path === '/client/sign_ins' && !('publicKeyCredential' in call[0].body); + + const setup = (expireAt: number) => { + const mockWebAuthnGetCredential = vi + .fn() + .mockResolvedValueOnce({ publicKeyCredential: null, error: new Error('aborted') }) + .mockResolvedValueOnce({ publicKeyCredential: credential, error: null }); + + SignIn.clerk = { + __internal_isWebAuthnSupported: vi.fn().mockReturnValue(true), + __internal_isWebAuthnAutofillSupported: vi.fn().mockResolvedValue(true), + __internal_getPublicCredentials: mockWebAuthnGetCredential, + __internal_environment: { displayConfig: { captchaOauthBypass: [] } }, + } as any; + + const mockFetch = vi + .fn() + .mockResolvedValueOnce(createResponse(expireAt)) + .mockResolvedValueOnce(createResponse(expireAt)) + .mockResolvedValueOnce({ client: null, response: { id: 'signin_123', status: 'complete' } }); + BaseResource._fetch = mockFetch; + return mockFetch; + }; + + it('reuses the challenge when autofill runs again', async () => { + const mockFetch = setup(Date.now() + 60_000); + const signIn = new SignIn(); + + const first = await signIn.__internal_future.passkey({ flow: 'autofill' }); + expect(first.error).not.toBeNull(); + const second = await signIn.__internal_future.passkey({ flow: 'autofill' }); + expect(second.error).toBeNull(); + + expect(mockFetch.mock.calls.filter(isCreate)).toHaveLength(1); + }); + + it('creates a new sign-in when the challenge expired', async () => { + const mockFetch = setup(Date.now() - 1_000); + const signIn = new SignIn(); + + await signIn.__internal_future.passkey({ flow: 'autofill' }); + await signIn.__internal_future.passkey({ flow: 'autofill' }); + + expect(mockFetch.mock.calls.filter(isCreate)).toHaveLength(2); + }); + + it('reuses the challenge in authenticateWithPasskey', async () => { + const mockFetch = setup(Date.now() + 60_000); + const signIn = new SignIn(); + + await expect(signIn.authenticateWithPasskey({ flow: 'autofill' })).rejects.toThrow('aborted'); + await signIn.authenticateWithPasskey({ flow: 'autofill' }); + + expect(mockFetch.mock.calls.filter(isCreate)).toHaveLength(1); + }); + }); + it('creates signIn with passkey for discoverable flow', async () => { const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true); const mockWebAuthnGetCredential = vi.fn().mockResolvedValue({ From 69832beec031aac961b6786c1bb6acf4e0e5ed00 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Thu, 10 Sep 2026 19:21:04 -0300 Subject: [PATCH 2/2] fix(clerk-js): reuse pending passkey challenge across autofill calls `authenticateWithPasskey` and `SignInFuture.passkey` called `create` on every `autofill` or `discoverable` call. The sign-in form runs autofill on each mount, so a form that mounts several times in a row created one sign-in attempt per mount and hit the sign-in creation rate limit. Skip `create` when `firstFactorVerification` already holds an unconsumed, unexpired passkey challenge. The existing challenge is reused for the WebAuthn request. An expired or consumed challenge still creates a new sign-in. --- .changeset/passkey-pending-challenge.md | 5 ++++ .../clerk-js/src/core/resources/SignIn.ts | 26 ++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 .changeset/passkey-pending-challenge.md diff --git a/.changeset/passkey-pending-challenge.md b/.changeset/passkey-pending-challenge.md new file mode 100644 index 00000000000..8b24dfceb3b --- /dev/null +++ b/.changeset/passkey-pending-challenge.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Reuse a pending passkey challenge when `authenticateWithPasskey` runs again with the `autofill` or `discoverable` flow, instead of creating a new sign-in attempt on every call. A sign-in form that mounts several times in a row no longer issues one `POST /v1/client/sign_ins` per mount. diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index c2de93a5030..7e94f6ea86b 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -110,6 +110,22 @@ import { BaseResource, UserData, Verification } from './internal'; const isTerminalEmailLinkVerificationStatus = (status: string | null) => status === 'verified' || status === 'expired' || status === 'transferable'; +/** + * True when `signIn` already holds a passkey challenge that no attempt has consumed and that has + * not expired. The sign-in form issues one passkey challenge per mount, so a remount would + * otherwise create a fresh sign-in attempt each time and trip the sign-in creation rate limit. + */ +function hasPendingPasskeyChallenge(signIn: SignIn): boolean { + const { strategy, nonce, status, expireAt } = signIn.firstFactorVerification; + return ( + strategy === 'passkey' && + !!nonce && + (status === null || status === 'unverified') && + !!expireAt && + expireAt.getTime() > Date.now() + ); +} + export class SignIn extends BaseResource implements SignInResource { pathRoot = '/client/sign_ins'; @@ -573,8 +589,10 @@ export class SignIn extends BaseResource implements SignInResource { } if (flow === 'autofill' || flow === 'discoverable') { - // @ts-ignore As this is experimental we want to support it at runtime, but not at the type level - await this.create({ strategy: 'passkey' }); + if (!hasPendingPasskeyChallenge(this)) { + // @ts-ignore As this is experimental we want to support it at runtime, but not at the type level + await this.create({ strategy: 'passkey' }); + } } else { // @ts-ignore As this is experimental we want to support it at runtime, but not at the type level const passKeyFactor = this.supportedFirstFactors.find( @@ -1397,7 +1415,9 @@ class SignInFuture implements SignInFutureResource { return runAsyncResourceTask(this.#resource, async () => { if (flow === 'autofill' || flow === 'discoverable') { - await this._create({ strategy: 'passkey' }); + if (!hasPendingPasskeyChallenge(this.#resource)) { + await this._create({ strategy: 'passkey' }); + } } else { const passKeyFactor = this.supportedFirstFactors.find(f => f.strategy === 'passkey') as PasskeyFactor;