From d5f77917918fdbcd821b8a82dde4741c0f7915ad Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Tue, 3 Feb 2026 15:23:34 -0500 Subject: [PATCH 01/12] feat(clerk-js): Add sign_up_if_missing for SignIn.create We are building a new sign-in-or-sign-up flow compatible with strict enumeration protection. It is meant to complement our existing sign-in-or-sign-up flow. Our current sign-in-or-sign-up flow is managed by the SDKs: We start with a sign in attempt, and on a 422 (when an account does not exist), we redirect to a sign up. The flow is thus: Sign In (422) -> Sign Up (200) -> Attempt Verifications for VerifiedAtSignUp identifiers (200). This is vulnerable to user enumeration attacks because the attacker sees the sign in to sign up redirect before they prove their identity by completing a verification. When `sign_up_if_missing` is passed as a param when POSTing a sign in, instead we do the following: Sign In (200) -> Attempt Verification for Identifier (200) -> Create User and Session. (In future work this third step will be modified to support adding additional information to the user, either via AccountTransfer or Session Tasks). This is enumeration safe, because you only see if an account already existed or was created after you verify your identity. This PR is the first step in SDK support for this new flow. We add support for the optional `sign_up_if_missing` param on `SignIn`. We also add captcha support for `SignIn`. This is all optional and currently in testing with custom components. Support in Clerk components will be in future PRs. --- .changeset/strict-needles-taste.md | 6 + .../clerk-js/src/core/resources/SignIn.ts | 125 +++++++++++++- .../core/resources/__tests__/SignIn.test.ts | 160 ++++++++++++++++++ packages/clerk-js/src/utils/captcha/types.ts | 2 +- packages/shared/src/types/signInCommon.ts | 14 +- packages/shared/src/types/signInFuture.ts | 23 +++ 6 files changed, 323 insertions(+), 7 deletions(-) create mode 100644 .changeset/strict-needles-taste.md diff --git a/.changeset/strict-needles-taste.md b/.changeset/strict-needles-taste.md new file mode 100644 index 00000000000..85d3b83791f --- /dev/null +++ b/.changeset/strict-needles-taste.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +Support `sign_up_if_missing` on SignIn.create, including captcha diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 998fb276c03..f7d40dcd484 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -16,6 +16,7 @@ import type { AuthenticateWithPopupParams, AuthenticateWithRedirectParams, AuthenticateWithWeb3Params, + CaptchaWidgetType, ClientTrustState, CreateEmailLinkFlowReturn, EmailCodeConfig, @@ -80,6 +81,7 @@ import { _futureAuthenticateWithPopup, wrapWithPopupRoutes, } from '../../utils/authenticateWithPopup'; +import { CaptchaChallenge } from '../../utils/captcha/CaptchaChallenge'; import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask'; import { loadZxcvbn } from '../../utils/zxcvbn'; import { @@ -162,12 +164,43 @@ export class SignIn extends BaseResource implements SignInResource { this.fromJSON(data); } - create = (params: SignInCreateParams): Promise => { + create = async (params: SignInCreateParams): Promise => { debugLogger.debug('SignIn.create', { id: this.id, strategy: 'strategy' in params ? params.strategy : undefined }); - const locale = getBrowserLocale(); + + let finalParams = { ...params }; + + // Inject browser locale if not already provided + if (!finalParams.locale) { + const browserLocale = getBrowserLocale(); + if (browserLocale) { + finalParams.locale = browserLocale; + } + } + + // Determine captcha requirement based on params + const requiresCaptcha = this.shouldRequireCaptcha(params); + + if (requiresCaptcha) { + const captchaChallenge = new CaptchaChallenge(SignIn.clerk); + const captchaParams = await captchaChallenge.managedOrInvisible({ action: 'signin' }); + if (!captchaParams) { + throw new ClerkRuntimeError('', { code: 'captcha_unavailable' }); + } + finalParams = { ...finalParams, ...captchaParams }; + } + + if (params.transfer && this.shouldBypassCaptchaForAttempt(params)) { + const strategy = SignIn.clerk.client?.signUp.verifications.externalAccount.strategy; + if (strategy) { + // When transfer is true, we're in the OAuth/Enterprise SSO transfer case + type TransferParams = Extract; + (finalParams as TransferParams).strategy = strategy as TransferParams['strategy']; + } + } + return this._basePost({ path: this.pathRoot, - body: locale ? { locale, ...params } : params, + body: finalParams, }); }; @@ -574,6 +607,59 @@ export class SignIn extends BaseResource implements SignInResource { return this; } + /** + * We delegate bot detection to the following providers, instead of relying on turnstile exclusively + * + * This is almost identical to SignUp.shouldBypassCaptchaForAttempt, but they differ because on transfer + * sign up needs to check the sign in, and sign in needs to check the sign up. + */ + protected shouldBypassCaptchaForAttempt(params: SignInCreateParams) { + if (!('strategy' in params) || !params.strategy) { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const captchaOauthBypass = SignIn.clerk.__internal_environment!.displayConfig.captchaOauthBypass; + + if (captchaOauthBypass.some(strategy => strategy === params.strategy)) { + return true; + } + + if ( + params.transfer && + captchaOauthBypass.some( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + strategy => strategy === SignIn.clerk.client!.signUp.verifications.externalAccount.strategy, + ) + ) { + return true; + } + + return false; + } + + /** + * Determines whether captcha is required based on the provided params. + */ + private shouldRequireCaptcha(params: SignInCreateParams): boolean { + // Always bypass for these conditions + if (__BUILD_DISABLE_RHC__) { + return false; + } + + if (SignIn.clerk.client?.captchaBypass) { + return false; + } + + // Strategy-based bypass (OAuth, etc.) + if (this.shouldBypassCaptchaForAttempt(params)) { + return false; + } + + // Require captcha if sign_up_if_missing is present + return !!params.sign_up_if_missing; + } + public __internal_toSnapshot(): SignInJSONSnapshot { return { object: 'sign_in', @@ -757,11 +843,42 @@ class SignInFuture implements SignInFutureResource { }); } + private async getCaptchaToken(): Promise<{ + captcha_token?: string; + captcha_widget_type?: CaptchaWidgetType; + captcha_error?: unknown; + }> { + const captchaChallenge = new CaptchaChallenge(SignIn.clerk); + const response = await captchaChallenge.managedOrInvisible({ action: 'signin' }); + if (!response) { + throw new Error('Captcha challenge failed'); + } + + const { captchaError, captchaToken, captchaWidgetType } = response; + return { captcha_token: captchaToken, captcha_widget_type: captchaWidgetType, captcha_error: captchaError }; + } + private async _create(params: SignInFutureCreateParams): Promise { const locale = getBrowserLocale(); + let body: Record = { ...params }; + if (locale) { + body.locale = locale; + } + + // Determine captcha requirement based on params + const requiresCaptcha = this.#resource['shouldRequireCaptcha']( + body, + 'strategy' in params ? params.strategy : undefined, + ); + + if (requiresCaptcha) { + const captchaParams = await this.getCaptchaToken(); + body = { ...body, ...captchaParams }; + } + await this.#resource.__internal_basePost({ path: this.#resource.pathRoot, - body: locale ? { locale, ...params } : params, + body, }); } 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 2fcdd4b0d10..f1caf53cdef 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -17,6 +17,16 @@ vi.mock('../../../utils/authenticateWithPopup', async () => { // Import the mocked function after mocking import { _futureAuthenticateWithPopup } from '../../../utils/authenticateWithPopup'; +// Mock the CaptchaChallenge module +vi.mock('../../../utils/captcha/CaptchaChallenge', () => ({ + CaptchaChallenge: vi.fn().mockImplementation(() => ({ + managedOrInvisible: vi.fn().mockResolvedValue({ + captchaToken: 'mock_captcha_token', + captchaWidgetType: 'invisible', + }), + })), +})); + describe('SignIn', () => { it('can be serialized with JSON.stringify', () => { const signIn = new SignIn(); @@ -40,6 +50,12 @@ describe('SignIn', () => { BaseResource._fetch = mockFetch; const signIn = new SignIn(); + SignIn.clerk = { + client: { + captchaBypass: false, + }, + } as any; + await signIn.create({ identifier: 'user@example.com' }); expect(mockFetch).toHaveBeenCalledWith( @@ -64,6 +80,12 @@ describe('SignIn', () => { BaseResource._fetch = mockFetch; const signIn = new SignIn(); + SignIn.clerk = { + client: { + captchaBypass: false, + }, + } as any; + await signIn.create({ identifier: 'user@example.com' }); expect(mockFetch).toHaveBeenCalledWith( @@ -76,6 +98,98 @@ describe('SignIn', () => { }), ); }); + + it('includes captcha params when sign_up_if_missing is true', async () => { + vi.stubGlobal('__BUILD_DISABLE_RHC__', false); + + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + SignIn.clerk = { + client: { + captchaBypass: false, + }, + isStandardBrowser: true, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + captchaPublicKey: 'test-site-key', + captchaPublicKeyInvisible: 'test-invisible-key', + captchaProvider: 'turnstile', + captchaWidgetType: 'invisible', + }, + userSettings: { + signUp: { + captcha_enabled: true, + }, + }, + }, + } as any; + + await signIn.create({ identifier: 'user@example.com', sign_up_if_missing: true }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ins', + body: expect.objectContaining({ + identifier: 'user@example.com', + sign_up_if_missing: true, + captchaToken: 'mock_captcha_token', + captchaWidgetType: 'invisible', + }), + }), + ); + }); + + it('excludes captcha params when sign_up_if_missing is false', async () => { + vi.stubGlobal('__BUILD_DISABLE_RHC__', false); + + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + SignIn.clerk = { + client: { + captchaBypass: false, + }, + isStandardBrowser: true, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + captchaPublicKey: 'test-site-key', + captchaPublicKeyInvisible: 'test-invisible-key', + captchaProvider: 'turnstile', + captchaWidgetType: 'invisible', + }, + userSettings: { + signUp: { + captcha_enabled: true, + }, + }, + }, + } as any; + + await signIn.create({ identifier: 'user@example.com', sign_up_if_missing: false }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ins', + body: expect.not.objectContaining({ + captchaToken: expect.anything(), + captchaWidgetType: expect.anything(), + }), + }), + ); + }); }); describe('SignInFuture', () => { @@ -1109,6 +1223,11 @@ describe('SignIn', () => { __internal_isWebAuthnSupported: mockIsWebAuthnSupported, __internal_isWebAuthnAutofillSupported: mockIsWebAuthnAutofillSupported, __internal_getPublicCredentials: mockWebAuthnGetCredential, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi @@ -1163,6 +1282,11 @@ describe('SignIn', () => { SignIn.clerk = { __internal_isWebAuthnSupported: mockIsWebAuthnSupported, __internal_getPublicCredentials: mockWebAuthnGetCredential, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi @@ -1236,6 +1360,11 @@ describe('SignIn', () => { SignIn.clerk = { __internal_isWebAuthnSupported: mockIsWebAuthnSupported, __internal_getPublicCredentials: mockWebAuthnGetCredential, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi.fn().mockResolvedValue({ @@ -1323,6 +1452,14 @@ describe('SignIn', () => { }); it('authenticates with metamask strategy', async () => { + SignIn.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + const mockFetch = vi .fn() .mockResolvedValueOnce({ @@ -1659,6 +1796,11 @@ describe('SignIn', () => { const mockBuildUrlWithAuth = vi.fn().mockReturnValue('https://example.com/sso-callback'); SignIn.clerk = { buildUrlWithAuth: mockBuildUrlWithAuth, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi @@ -1707,6 +1849,11 @@ describe('SignIn', () => { const mockBuildUrlWithAuth = vi.fn().mockReturnValue('https://example.com/sso-callback'); SignIn.clerk = { buildUrlWithAuth: mockBuildUrlWithAuth, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi.fn().mockResolvedValue({ @@ -1755,6 +1902,11 @@ describe('SignIn', () => { buildUrlWithAuth: mockBuildUrlWithAuth, buildUrl: vi.fn().mockImplementation(path => 'https://example.com' + path), frontendApi: 'clerk.example.com', + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; const mockFetch = vi.fn(); @@ -1830,6 +1982,14 @@ describe('SignIn', () => { }); vi.stubGlobal('URLSearchParams', vi.fn().mockReturnValue(mockSearchParams)); + SignIn.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + const mockFetch = vi.fn().mockResolvedValue({ client: null, response: { id: 'signin_123' }, diff --git a/packages/clerk-js/src/utils/captcha/types.ts b/packages/clerk-js/src/utils/captcha/types.ts index 37abf1aaca8..b7c13fa7858 100644 --- a/packages/clerk-js/src/utils/captcha/types.ts +++ b/packages/clerk-js/src/utils/captcha/types.ts @@ -1,7 +1,7 @@ import type { CaptchaProvider, CaptchaWidgetType } from '@clerk/shared/types'; export type CaptchaOptions = { - action?: 'verify' | 'signup' | 'heartbeat'; + action?: 'verify' | 'signin' | 'signup' | 'heartbeat'; captchaProvider: CaptchaProvider; closeModal?: () => Promise; invisibleSiteKey: string; diff --git a/packages/shared/src/types/signInCommon.ts b/packages/shared/src/types/signInCommon.ts index 52917cd1293..e86b5db2bfd 100644 --- a/packages/shared/src/types/signInCommon.ts +++ b/packages/shared/src/types/signInCommon.ts @@ -162,8 +162,18 @@ export type SignInCreateParams = ( | { identifier: string; } - | { transfer?: boolean } -) & { transfer?: boolean }; + | { + transfer: true; + strategy?: OAuthStrategy | EnterpriseSSOStrategy; + } +) & { + transfer?: boolean; + locale?: string; + sign_up_if_missing?: boolean; + captcha_token?: string; + captcha_error?: unknown; + captcha_widget_type?: string | null; +}; export type ResetPasswordParams = { password: string; diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index 12e21236970..0253bfcf4f3 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -37,6 +37,29 @@ export interface SignInFutureCreateParams { * generated from the Backend API. **Required** if `strategy` is set to `'ticket'`. */ ticket?: string; + /** + * When set to `true`, if a user does not exist, the sign-in will create a new account automatically. + * Captcha will be required when this is enabled. + */ + sign_up_if_missing?: boolean; + /** + * The captcha token returned from the captcha challenge. + * + * @internal + */ + captcha_token?: string; + /** + * The captcha error if the captcha challenge failed. + * + * @internal + */ + captcha_error?: unknown; + /** + * The type of captcha widget used ('smart', 'invisible', or null). + * + * @internal + */ + captcha_widget_type?: string | null; } export type SignInFuturePasswordParams = { From 5c17f4b8ec6b6e1af802957372918a9aacd3cb83 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Tue, 3 Feb 2026 16:08:57 -0500 Subject: [PATCH 02/12] fix: Synchronize SignIn and SignInFuture, add tests --- .../clerk-js/src/core/resources/SignIn.ts | 62 ++++----- .../core/resources/__tests__/SignIn.test.ts | 130 ++++++++++++++++++ packages/shared/src/types/signInFuture.ts | 5 + 3 files changed, 163 insertions(+), 34 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index f7d40dcd484..f1ccc93bd7f 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -169,23 +169,17 @@ export class SignIn extends BaseResource implements SignInResource { let finalParams = { ...params }; - // Inject browser locale if not already provided - if (!finalParams.locale) { - const browserLocale = getBrowserLocale(); - if (browserLocale) { - finalParams.locale = browserLocale; - } + // Inject browser locale + const browserLocale = getBrowserLocale(); + if (browserLocale) { + finalParams.locale = browserLocale; } // Determine captcha requirement based on params const requiresCaptcha = this.shouldRequireCaptcha(params); if (requiresCaptcha) { - const captchaChallenge = new CaptchaChallenge(SignIn.clerk); - const captchaParams = await captchaChallenge.managedOrInvisible({ action: 'signin' }); - if (!captchaParams) { - throw new ClerkRuntimeError('', { code: 'captcha_unavailable' }); - } + const captchaParams = await this.getCaptchaToken(); finalParams = { ...finalParams, ...captchaParams }; } @@ -613,7 +607,7 @@ export class SignIn extends BaseResource implements SignInResource { * This is almost identical to SignUp.shouldBypassCaptchaForAttempt, but they differ because on transfer * sign up needs to check the sign in, and sign in needs to check the sign up. */ - protected shouldBypassCaptchaForAttempt(params: SignInCreateParams) { + protected shouldBypassCaptchaForAttempt(params: { strategy?: string; transfer?: boolean }) { if (!('strategy' in params) || !params.strategy) { return false; } @@ -641,7 +635,11 @@ export class SignIn extends BaseResource implements SignInResource { /** * Determines whether captcha is required based on the provided params. */ - private shouldRequireCaptcha(params: SignInCreateParams): boolean { + private shouldRequireCaptcha(params: { + strategy?: string; + transfer?: boolean; + sign_up_if_missing?: boolean; + }): boolean { // Always bypass for these conditions if (__BUILD_DISABLE_RHC__) { return false; @@ -660,6 +658,19 @@ export class SignIn extends BaseResource implements SignInResource { return !!params.sign_up_if_missing; } + /** + * Gets captcha token and widget type from the captcha challenge. + * Throws if captcha is unavailable. + */ + private async getCaptchaToken() { + const captchaChallenge = new CaptchaChallenge(SignIn.clerk); + const captchaParams = await captchaChallenge.managedOrInvisible({ action: 'signin' }); + if (!captchaParams) { + throw new ClerkRuntimeError('', { code: 'captcha_unavailable' }); + } + return captchaParams; + } + public __internal_toSnapshot(): SignInJSONSnapshot { return { object: 'sign_in', @@ -843,36 +854,19 @@ class SignInFuture implements SignInFutureResource { }); } - private async getCaptchaToken(): Promise<{ - captcha_token?: string; - captcha_widget_type?: CaptchaWidgetType; - captcha_error?: unknown; - }> { - const captchaChallenge = new CaptchaChallenge(SignIn.clerk); - const response = await captchaChallenge.managedOrInvisible({ action: 'signin' }); - if (!response) { - throw new Error('Captcha challenge failed'); - } - - const { captchaError, captchaToken, captchaWidgetType } = response; - return { captcha_token: captchaToken, captcha_widget_type: captchaWidgetType, captcha_error: captchaError }; - } - private async _create(params: SignInFutureCreateParams): Promise { + let body = { ...params }; + const locale = getBrowserLocale(); - let body: Record = { ...params }; if (locale) { body.locale = locale; } // Determine captcha requirement based on params - const requiresCaptcha = this.#resource['shouldRequireCaptcha']( - body, - 'strategy' in params ? params.strategy : undefined, - ); + const requiresCaptcha = this.#resource['shouldRequireCaptcha'](body); if (requiresCaptcha) { - const captchaParams = await this.getCaptchaToken(); + const captchaParams = await this.#resource['getCaptchaToken'](); body = { ...body, ...captchaParams }; } 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 f1caf53cdef..b6edf6b9f1f 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -360,6 +360,136 @@ describe('SignIn', () => { expect(result).toHaveProperty('error', mockError); }); + + it('includes captcha params when sign_up_if_missing is true', async () => { + vi.stubGlobal('__BUILD_DISABLE_RHC__', false); + + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + SignIn.clerk = { + client: { + captchaBypass: false, + }, + isStandardBrowser: true, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + captchaPublicKey: 'test-site-key', + captchaPublicKeyInvisible: 'test-invisible-key', + captchaProvider: 'turnstile', + captchaWidgetType: 'invisible', + }, + userSettings: { + signUp: { + captcha_enabled: true, + }, + }, + }, + } as any; + + await signIn.__internal_future.create({ identifier: 'user@example.com', sign_up_if_missing: true }); + + expect(mockFetch).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/sign_ins', + body: { + identifier: 'user@example.com', + sign_up_if_missing: true, + captchaToken: 'mock_captcha_token', + captchaWidgetType: 'invisible', + }, + }); + }); + + it('excludes captcha params when sign_up_if_missing is false', async () => { + vi.stubGlobal('__BUILD_DISABLE_RHC__', false); + + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + SignIn.clerk = { + client: { + captchaBypass: false, + }, + isStandardBrowser: true, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + captchaPublicKey: 'test-site-key', + captchaPublicKeyInvisible: 'test-invisible-key', + captchaProvider: 'turnstile', + captchaWidgetType: 'invisible', + }, + userSettings: { + signUp: { + captcha_enabled: true, + }, + }, + }, + } as any; + + await signIn.__internal_future.create({ identifier: 'user@example.com', sign_up_if_missing: false }); + + expect(mockFetch).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/sign_ins', + body: { + identifier: 'user@example.com', + sign_up_if_missing: false, + }, + }); + }); + + it('excludes captcha params when sign_up_if_missing is not provided', async () => { + vi.stubGlobal('__BUILD_DISABLE_RHC__', false); + + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor' }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + SignIn.clerk = { + client: { + captchaBypass: false, + }, + isStandardBrowser: true, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + captchaPublicKey: 'test-site-key', + captchaPublicKeyInvisible: 'test-invisible-key', + captchaProvider: 'turnstile', + captchaWidgetType: 'invisible', + }, + userSettings: { + signUp: { + captcha_enabled: true, + }, + }, + }, + } as any; + + await signIn.__internal_future.create({ identifier: 'user@example.com' }); + + expect(mockFetch).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/sign_ins', + body: { + identifier: 'user@example.com', + }, + }); + }); }); describe('password', () => { diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index 0253bfcf4f3..f3cc8264e38 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -37,6 +37,11 @@ export interface SignInFutureCreateParams { * generated from the Backend API. **Required** if `strategy` is set to `'ticket'`. */ ticket?: string; + /** + * The locale to assign to the user in [BCP 47](https://developer.mozilla.org/en-US/docs/Glossary/BCP_47_language_tag) + * format (e.g., "en-US", "fr-FR"). Set from the browser's locale. + */ + locale?: string; /** * When set to `true`, if a user does not exist, the sign-in will create a new account automatically. * Captcha will be required when this is enabled. From 4998b6a11bf2b8798e89bc73b5e0f5ceb119a095 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Tue, 3 Feb 2026 16:12:22 -0500 Subject: [PATCH 03/12] chore: Remove unused import --- packages/clerk-js/src/core/resources/SignIn.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index f1ccc93bd7f..e59d88d9a6d 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -16,7 +16,6 @@ import type { AuthenticateWithPopupParams, AuthenticateWithRedirectParams, AuthenticateWithWeb3Params, - CaptchaWidgetType, ClientTrustState, CreateEmailLinkFlowReturn, EmailCodeConfig, From 968af9e189b35fdb8786ea3b11f6a68e024db534 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Tue, 3 Feb 2026 16:24:43 -0500 Subject: [PATCH 04/12] chore: Bump bundlewatch size --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 3bd20ec6fee..d716dd2b5f3 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,7 +4,7 @@ { "path": "./dist/clerk.browser.js", "maxSize": "66KB" }, { "path": "./dist/clerk.chips.browser.js", "maxSize": "66KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "106KB" }, - { "path": "./dist/clerk.no-rhc.js", "maxSize": "305KB" }, + { "path": "./dist/clerk.no-rhc.js", "maxSize": "306KB" }, { "path": "./dist/clerk.native.js", "maxSize": "65KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, From c83c9edbdfc872fc5f772d8f28e47f8d6844fce0 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Thu, 12 Feb 2026 15:17:12 -0500 Subject: [PATCH 05/12] fix: Remove frontend validation logic that duplicates backend --- packages/clerk-js/src/core/resources/SignIn.ts | 9 --------- packages/shared/src/types/signInCommon.ts | 3 +-- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index c2183b89729..cfc840ec1d1 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -184,15 +184,6 @@ export class SignIn extends BaseResource implements SignInResource { finalParams = { ...finalParams, ...captchaParams }; } - if (params.transfer && this.shouldBypassCaptchaForAttempt(params)) { - const strategy = SignIn.clerk.client?.signUp.verifications.externalAccount.strategy; - if (strategy) { - // When transfer is true, we're in the OAuth/Enterprise SSO transfer case - type TransferParams = Extract; - (finalParams as TransferParams).strategy = strategy as TransferParams['strategy']; - } - } - return this._basePost({ path: this.pathRoot, body: finalParams, diff --git a/packages/shared/src/types/signInCommon.ts b/packages/shared/src/types/signInCommon.ts index e86b5db2bfd..7752b569b8c 100644 --- a/packages/shared/src/types/signInCommon.ts +++ b/packages/shared/src/types/signInCommon.ts @@ -163,8 +163,7 @@ export type SignInCreateParams = ( identifier: string; } | { - transfer: true; - strategy?: OAuthStrategy | EnterpriseSSOStrategy; + transfer?: boolean; } ) & { transfer?: boolean; From 3375f691dea3a8189476128b68c3d1ec97ec392d Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Thu, 12 Feb 2026 15:18:18 -0500 Subject: [PATCH 06/12] chore: Use camelCase for external params --- .../clerk-js/src/core/resources/SignIn.ts | 10 +++----- .../core/resources/__tests__/SignIn.test.ts | 24 +++++++++---------- packages/shared/src/types/signInCommon.ts | 2 +- packages/shared/src/types/signInFuture.ts | 6 ++--- 4 files changed, 19 insertions(+), 23 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index cfc840ec1d1..9634a4fd5ec 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -627,11 +627,7 @@ export class SignIn extends BaseResource implements SignInResource { /** * Determines whether captcha is required based on the provided params. */ - private shouldRequireCaptcha(params: { - strategy?: string; - transfer?: boolean; - sign_up_if_missing?: boolean; - }): boolean { + private shouldRequireCaptcha(params: { strategy?: string; transfer?: boolean; signUpIfMissing?: boolean }): boolean { // Always bypass for these conditions if (__BUILD_DISABLE_RHC__) { return false; @@ -646,8 +642,8 @@ export class SignIn extends BaseResource implements SignInResource { return false; } - // Require captcha if sign_up_if_missing is present - return !!params.sign_up_if_missing; + // Require captcha if signUpIfMissing is present + return !!params.signUpIfMissing; } /** 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 ee4f4804e65..330dea610e3 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -99,7 +99,7 @@ describe('SignIn', () => { ); }); - it('includes captcha params when sign_up_if_missing is true', async () => { + it('includes captcha params when signUpIfMissing is true', async () => { vi.stubGlobal('__BUILD_DISABLE_RHC__', false); const mockFetch = vi.fn().mockResolvedValue({ @@ -130,7 +130,7 @@ describe('SignIn', () => { }, } as any; - await signIn.create({ identifier: 'user@example.com', sign_up_if_missing: true }); + await signIn.create({ identifier: 'user@example.com', signUpIfMissing: true }); expect(mockFetch).toHaveBeenCalledWith( expect.objectContaining({ @@ -138,7 +138,7 @@ describe('SignIn', () => { path: '/client/sign_ins', body: expect.objectContaining({ identifier: 'user@example.com', - sign_up_if_missing: true, + signUpIfMissing: true, captchaToken: 'mock_captcha_token', captchaWidgetType: 'invisible', }), @@ -146,7 +146,7 @@ describe('SignIn', () => { ); }); - it('excludes captcha params when sign_up_if_missing is false', async () => { + it('excludes captcha params when signUpIfMissing is false', async () => { vi.stubGlobal('__BUILD_DISABLE_RHC__', false); const mockFetch = vi.fn().mockResolvedValue({ @@ -177,7 +177,7 @@ describe('SignIn', () => { }, } as any; - await signIn.create({ identifier: 'user@example.com', sign_up_if_missing: false }); + await signIn.create({ identifier: 'user@example.com', signUpIfMissing: false }); expect(mockFetch).toHaveBeenCalledWith( expect.objectContaining({ @@ -361,7 +361,7 @@ describe('SignIn', () => { expect(result).toHaveProperty('error', mockError); }); - it('includes captcha params when sign_up_if_missing is true', async () => { + it('includes captcha params when signUpIfMissing is true', async () => { vi.stubGlobal('__BUILD_DISABLE_RHC__', false); const mockFetch = vi.fn().mockResolvedValue({ @@ -392,21 +392,21 @@ describe('SignIn', () => { }, } as any; - await signIn.__internal_future.create({ identifier: 'user@example.com', sign_up_if_missing: true }); + await signIn.__internal_future.create({ identifier: 'user@example.com', signUpIfMissing: true }); expect(mockFetch).toHaveBeenCalledWith({ method: 'POST', path: '/client/sign_ins', body: { identifier: 'user@example.com', - sign_up_if_missing: true, + signUpIfMissing: true, captchaToken: 'mock_captcha_token', captchaWidgetType: 'invisible', }, }); }); - it('excludes captcha params when sign_up_if_missing is false', async () => { + it('excludes captcha params when signUpIfMissing is false', async () => { vi.stubGlobal('__BUILD_DISABLE_RHC__', false); const mockFetch = vi.fn().mockResolvedValue({ @@ -437,19 +437,19 @@ describe('SignIn', () => { }, } as any; - await signIn.__internal_future.create({ identifier: 'user@example.com', sign_up_if_missing: false }); + await signIn.__internal_future.create({ identifier: 'user@example.com', signUpIfMissing: false }); expect(mockFetch).toHaveBeenCalledWith({ method: 'POST', path: '/client/sign_ins', body: { identifier: 'user@example.com', - sign_up_if_missing: false, + signUpIfMissing: false, }, }); }); - it('excludes captcha params when sign_up_if_missing is not provided', async () => { + it('excludes captcha params when signUpIfMissing is not provided', async () => { vi.stubGlobal('__BUILD_DISABLE_RHC__', false); const mockFetch = vi.fn().mockResolvedValue({ diff --git a/packages/shared/src/types/signInCommon.ts b/packages/shared/src/types/signInCommon.ts index 7752b569b8c..1d2a27d73bd 100644 --- a/packages/shared/src/types/signInCommon.ts +++ b/packages/shared/src/types/signInCommon.ts @@ -168,7 +168,7 @@ export type SignInCreateParams = ( ) & { transfer?: boolean; locale?: string; - sign_up_if_missing?: boolean; + signUpIfMissing?: boolean; captcha_token?: string; captcha_error?: unknown; captcha_widget_type?: string | null; diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index 613b1cbcca2..60839b01802 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -43,10 +43,10 @@ export interface SignInFutureCreateParams { */ locale?: string; /** - * When set to `true`, if a user does not exist, the sign-in will create a new account automatically. - * Captcha will be required when this is enabled. + * When set to `true`, if a user does not exist, the sign-up will prepare a transfer to sign up a new + * account. If bot sign-up protection is enabled, captcha will also be required on sign in. */ - sign_up_if_missing?: boolean; + signUpIfMissing?: boolean; /** * The captcha token returned from the captcha challenge. * From c9b7d772cc63d46d1c6650c9cb60c68cae001b9e Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Thu, 12 Feb 2026 15:36:57 -0500 Subject: [PATCH 07/12] fix: Remove duplicated verification logic --- packages/clerk-js/src/core/resources/SignUp.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index e5d384be69e..e9861cfb3a6 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -173,13 +173,6 @@ export class SignUp extends BaseResource implements SignUpResource { finalParams = { ...finalParams, ...captchaParams }; } - if (finalParams.transfer && this.shouldBypassCaptchaForAttempt(finalParams)) { - const strategy = SignUp.clerk.client?.signIn.firstFactorVerification.strategy; - if (strategy) { - finalParams = { ...finalParams, strategy: strategy as SignUpCreateParams['strategy'] }; - } - } - return this._basePost({ path: this.pathRoot, body: normalizeUnsafeMetadata(finalParams), From 396be4ecc1a708e4419131917b657aa2e5511dee Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Thu, 12 Feb 2026 15:38:06 -0500 Subject: [PATCH 08/12] chore: Switch remaining snake case properties to camelCase --- packages/shared/src/types/signInCommon.ts | 6 +++--- packages/shared/src/types/signInFuture.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/types/signInCommon.ts b/packages/shared/src/types/signInCommon.ts index 1d2a27d73bd..80d6f905513 100644 --- a/packages/shared/src/types/signInCommon.ts +++ b/packages/shared/src/types/signInCommon.ts @@ -169,9 +169,9 @@ export type SignInCreateParams = ( transfer?: boolean; locale?: string; signUpIfMissing?: boolean; - captcha_token?: string; - captcha_error?: unknown; - captcha_widget_type?: string | null; + captchaToken?: string; + captchaError?: unknown; + captchaWidgetType?: string | null; }; export type ResetPasswordParams = { diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index 60839b01802..7138c0f1b74 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -52,19 +52,19 @@ export interface SignInFutureCreateParams { * * @internal */ - captcha_token?: string; + captchaToken?: string; /** * The captcha error if the captcha challenge failed. * * @internal */ - captcha_error?: unknown; + captchaError?: unknown; /** * The type of captcha widget used ('smart', 'invisible', or null). * * @internal */ - captcha_widget_type?: string | null; + captchaWidgetType?: string | null; } export type SignInFuturePasswordParams = { From 0b901d29587850719ba36ee675c38496ebf8d8ba Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Thu, 12 Feb 2026 16:22:11 -0500 Subject: [PATCH 09/12] fix: On SignUp, do not show captcha again if from signUpIfMissing --- .../clerk-js/src/core/resources/SignUp.ts | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index e9861cfb3a6..5433a531192 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -554,22 +554,24 @@ export class SignUp extends BaseResource implements SignUpResource { * We delegate bot detection to the following providers, instead of relying on turnstile exclusively */ protected shouldBypassCaptchaForAttempt(params: SignUpCreateParams) { - if (!params.strategy) { - return false; - } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const captchaOauthBypass = SignUp.clerk.__internal_environment!.displayConfig.captchaOauthBypass; - if (captchaOauthBypass.some(strategy => strategy === params.strategy)) { + // For transfers, inspect the SignIn strategy to determine bypass logic + if (params.transfer && SignUp.clerk.client?.signIn?.firstFactorVerification?.status === 'transferable') { + const signInStrategy = SignUp.clerk.client.signIn.firstFactorVerification.strategy; + + // OAuth transfer: Check if strategy is in bypass list + if (signInStrategy?.startsWith('oauth_')) { + return captchaOauthBypass.some(strategy => strategy === signInStrategy); + } + + // Non-OAuth transfer (signUpIfMissing): Captcha already validated during SignIn return true; } - if ( - params.transfer && - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - captchaOauthBypass.some(strategy => strategy === SignUp.clerk.client!.signIn.firstFactorVerification.strategy) - ) { + // For direct SignUp (not transfer), check OAuth bypass + if (params.strategy && captchaOauthBypass.some(strategy => strategy === params.strategy)) { return true; } From 5e93c7df78b22038d93f8b1539227db9c8b5809a Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Fri, 13 Feb 2026 13:25:30 -0500 Subject: [PATCH 10/12] fix: Sync sign in and sign up captcha logic --- .../clerk-js/src/core/resources/SignIn.ts | 158 +++++++++++------- .../clerk-js/src/core/resources/SignUp.ts | 22 +-- .../core/resources/__tests__/SignIn.test.ts | 10 ++ .../core/resources/__tests__/SignUp.test.ts | 75 ++++++++- packages/shared/src/types/signInCommon.ts | 4 - packages/shared/src/types/signInFuture.ts | 23 --- 6 files changed, 193 insertions(+), 99 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 9634a4fd5ec..deb7dcc04e6 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -16,6 +16,7 @@ import type { AuthenticateWithPopupParams, AuthenticateWithRedirectParams, AuthenticateWithWeb3Params, + CaptchaWidgetType, ClientTrustState, CreateEmailLinkFlowReturn, EmailCodeConfig, @@ -176,11 +177,17 @@ export class SignIn extends BaseResource implements SignInResource { finalParams.locale = browserLocale; } - // Determine captcha requirement based on params - const requiresCaptcha = this.shouldRequireCaptcha(params); - - if (requiresCaptcha) { - const captchaParams = await this.getCaptchaToken(); + if ( + this.shouldRequireCaptcha(params) && + !__BUILD_DISABLE_RHC__ && + !this.clientBypass() && + !this.shouldBypassCaptchaForAttempt(params) + ) { + const captchaChallenge = new CaptchaChallenge(SignIn.clerk); + const captchaParams = await captchaChallenge.managedOrInvisible({ action: 'signin' }); + if (!captchaParams) { + throw new ClerkRuntimeError('', { code: 'captcha_unavailable' }); + } finalParams = { ...finalParams, ...captchaParams }; } @@ -593,31 +600,16 @@ export class SignIn extends BaseResource implements SignInResource { return this; } + private clientBypass() { + return SignIn.clerk.client?.captchaBypass; + } + /** - * We delegate bot detection to the following providers, instead of relying on turnstile exclusively - * - * This is almost identical to SignUp.shouldBypassCaptchaForAttempt, but they differ because on transfer - * sign up needs to check the sign in, and sign in needs to check the sign up. + * Determines whether captcha is required for sign in based on the provided params. + * Add new conditions here as captcha requirements evolve. */ - protected shouldBypassCaptchaForAttempt(params: { strategy?: string; transfer?: boolean }) { - if (!('strategy' in params) || !params.strategy) { - return false; - } - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const captchaOauthBypass = SignIn.clerk.__internal_environment!.displayConfig.captchaOauthBypass; - - if (captchaOauthBypass.some(strategy => strategy === params.strategy)) { - return true; - } - - if ( - params.transfer && - captchaOauthBypass.some( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - strategy => strategy === SignIn.clerk.client!.signUp.verifications.externalAccount.strategy, - ) - ) { + private shouldRequireCaptcha(params: SignInCreateParams): boolean { + if ('signUpIfMissing' in params && params.signUpIfMissing) { return true; } @@ -625,38 +617,24 @@ export class SignIn extends BaseResource implements SignInResource { } /** - * Determines whether captcha is required based on the provided params. + * We delegate bot detection to the following providers, instead of relying on turnstile exclusively */ - private shouldRequireCaptcha(params: { strategy?: string; transfer?: boolean; signUpIfMissing?: boolean }): boolean { - // Always bypass for these conditions - if (__BUILD_DISABLE_RHC__) { - return false; - } + protected shouldBypassCaptchaForAttempt(params: SignInCreateParams) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const captchaOauthBypass = SignIn.clerk.__internal_environment!.displayConfig.captchaOauthBypass; - if (SignIn.clerk.client?.captchaBypass) { - return false; + // Check transfer strategy against bypass list + if (params.transfer && SignIn.clerk.client?.signUp?.verifications?.externalAccount?.status === 'transferable') { + const signUpStrategy = SignIn.clerk.client.signUp.verifications.externalAccount.strategy; + return signUpStrategy ? captchaOauthBypass.some(strategy => strategy === signUpStrategy) : false; } - // Strategy-based bypass (OAuth, etc.) - if (this.shouldBypassCaptchaForAttempt(params)) { - return false; + // Check direct strategy against bypass list + if ('strategy' in params && params.strategy) { + return captchaOauthBypass.some(strategy => strategy === params.strategy); } - // Require captcha if signUpIfMissing is present - return !!params.signUpIfMissing; - } - - /** - * Gets captcha token and widget type from the captcha challenge. - * Throws if captcha is unavailable. - */ - private async getCaptchaToken() { - const captchaChallenge = new CaptchaChallenge(SignIn.clerk); - const captchaParams = await captchaChallenge.managedOrInvisible({ action: 'signin' }); - if (!captchaParams) { - throw new ClerkRuntimeError('', { code: 'captcha_unavailable' }); - } - return captchaParams; + return false; } public __internal_updateFromJSON(data: SignInJSON | SignInJSONSnapshot | null): this { @@ -897,20 +875,80 @@ class SignInFuture implements SignInFutureResource { }); } + /** + * Determines whether captcha is required for sign in based on the provided params. + * Add new conditions here as captcha requirements evolve. + */ + private shouldRequireCaptcha(params: { signUpIfMissing?: boolean }): boolean { + if (params.signUpIfMissing) { + return true; + } + + return false; + } + + private shouldBypassCaptchaForAttempt(params: { strategy?: string; transfer?: boolean }) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const captchaOauthBypass = SignIn.clerk.__internal_environment!.displayConfig.captchaOauthBypass; + + // Check transfer strategy against bypass list + if (params.transfer && SignIn.clerk.client?.signUp?.verifications?.externalAccount?.status === 'transferable') { + const signUpStrategy = SignIn.clerk.client.signUp.verifications.externalAccount.strategy; + return signUpStrategy ? captchaOauthBypass.some(strategy => strategy === signUpStrategy) : false; + } + + // Check direct strategy against bypass list + if (params.strategy) { + return captchaOauthBypass.some(strategy => strategy === params.strategy); + } + + return false; + } + + private async getCaptchaToken( + params: { strategy?: string; transfer?: boolean; signUpIfMissing?: boolean } = {}, + ): Promise<{ + captchaToken?: string; + captchaWidgetType?: CaptchaWidgetType; + captchaError?: unknown; + }> { + if ( + !this.shouldRequireCaptcha(params) || + __BUILD_DISABLE_RHC__ || + SignIn.clerk.client?.captchaBypass || + this.shouldBypassCaptchaForAttempt(params) + ) { + return { + captchaToken: undefined, + captchaWidgetType: undefined, + captchaError: undefined, + }; + } + + const captchaChallenge = new CaptchaChallenge(SignIn.clerk); + const response = await captchaChallenge.managedOrInvisible({ action: 'signin' }); + if (!response) { + throw new Error('Captcha challenge failed'); + } + + const { captchaError, captchaToken, captchaWidgetType } = response; + return { captchaToken, captchaWidgetType, captchaError }; + } + private async _create(params: SignInFutureCreateParams): Promise { - let body = { ...params }; + const body: Record = { ...params }; const locale = getBrowserLocale(); if (locale) { body.locale = locale; } - // Determine captcha requirement based on params - const requiresCaptcha = this.#resource['shouldRequireCaptcha'](body); + const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken(params); - if (requiresCaptcha) { - const captchaParams = await this.#resource['getCaptchaToken'](); - body = { ...body, ...captchaParams }; + if (captchaToken !== undefined) { + body.captchaToken = captchaToken; + body.captchaWidgetType = captchaWidgetType; + body.captchaError = captchaError; } await this.#resource.__internal_basePost({ diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 7292370cd62..e1b0e60f50e 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -782,22 +782,24 @@ class SignUpFuture implements SignUpFutureResource { } private shouldBypassCaptchaForAttempt(params: { strategy?: string; transfer?: boolean }) { - if (!params.strategy) { - return false; - } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const captchaOauthBypass = SignUp.clerk.__internal_environment!.displayConfig.captchaOauthBypass; - if (captchaOauthBypass.some(strategy => strategy === params.strategy)) { + // For transfers, inspect the SignIn strategy to determine bypass logic + if (params.transfer && SignUp.clerk.client?.signIn?.firstFactorVerification?.status === 'transferable') { + const signInStrategy = SignUp.clerk.client.signIn.firstFactorVerification.strategy; + + // OAuth transfer: Check if strategy is in bypass list + if (signInStrategy?.startsWith('oauth_')) { + return captchaOauthBypass.some(strategy => strategy === signInStrategy); + } + + // Non-OAuth transfer (signUpIfMissing): Captcha already validated during SignIn return true; } - if ( - params.transfer && - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - captchaOauthBypass.some(strategy => strategy === SignUp.clerk.client!.signIn.firstFactorVerification.strategy) - ) { + // For direct SignUp (not transfer), check OAuth bypass + if (params.strategy && captchaOauthBypass.some(strategy => strategy === params.strategy)) { return true; } 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 330dea610e3..76d82b08de8 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -54,6 +54,11 @@ describe('SignIn', () => { client: { captchaBypass: false, }, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; await signIn.create({ identifier: 'user@example.com' }); @@ -84,6 +89,11 @@ describe('SignIn', () => { client: { captchaBypass: false, }, + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, } as any; await signIn.create({ identifier: 'user@example.com' }); diff --git a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts index 9723f8b751e..917bb1549df 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -44,7 +44,13 @@ describe('SignUp', () => { describe('create', () => { beforeEach(() => { - SignUp.clerk = {} as any; + SignUp.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; }); afterEach(() => { @@ -152,6 +158,37 @@ describe('SignUp', () => { expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaWidgetType', undefined); expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaError', undefined); }); + + it('skips captcha challenge for non-OAuth transfer (sign_up_if_missing)', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + SignUp.clerk = { + client: { + signIn: { + firstFactorVerification: { + status: 'transferable', + strategy: 'email_code', + }, + }, + }, + __internal_environment: { + displayConfig: { + captchaOauthBypass: ['oauth_google', 'oauth_apple'], + }, + }, + } as any; + + const signUp = new SignUp(); + await signUp.__internal_future.create({ transfer: true }); + + expect(CaptchaChallenge).not.toHaveBeenCalled(); + expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaToken', undefined); + expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaWidgetType', undefined); + expect(mockFetch.mock.calls[0]?.[0].body).toHaveProperty('captchaError', undefined); + }); }); describe('sendPhoneCode', () => { @@ -161,6 +198,14 @@ describe('SignUp', () => { }); it('creates signup with phoneNumber when no existing signup', async () => { + SignUp.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + const mockFetch = vi .fn() .mockResolvedValueOnce({ @@ -590,7 +635,13 @@ describe('SignUp', () => { describe('web3', () => { beforeEach(() => { - SignUp.clerk = {} as any; + SignUp.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; }); afterEach(() => { @@ -846,6 +897,16 @@ describe('SignUp', () => { }); describe('password', () => { + beforeEach(() => { + SignUp.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + }); + afterEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); @@ -911,6 +972,16 @@ describe('SignUp', () => { }); describe('ticket', () => { + beforeEach(() => { + SignUp.clerk = { + __internal_environment: { + displayConfig: { + captchaOauthBypass: [], + }, + }, + } as any; + }); + afterEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); diff --git a/packages/shared/src/types/signInCommon.ts b/packages/shared/src/types/signInCommon.ts index 80d6f905513..40e255b8cf1 100644 --- a/packages/shared/src/types/signInCommon.ts +++ b/packages/shared/src/types/signInCommon.ts @@ -167,11 +167,7 @@ export type SignInCreateParams = ( } ) & { transfer?: boolean; - locale?: string; signUpIfMissing?: boolean; - captchaToken?: string; - captchaError?: unknown; - captchaWidgetType?: string | null; }; export type ResetPasswordParams = { diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index 7138c0f1b74..4b5bdd1a4a7 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -37,34 +37,11 @@ export interface SignInFutureCreateParams { * generated from the Backend API. **Required** if `strategy` is set to `'ticket'`. */ ticket?: string; - /** - * The locale to assign to the user in [BCP 47](https://developer.mozilla.org/en-US/docs/Glossary/BCP_47_language_tag) - * format (e.g., "en-US", "fr-FR"). Set from the browser's locale. - */ - locale?: string; /** * When set to `true`, if a user does not exist, the sign-up will prepare a transfer to sign up a new * account. If bot sign-up protection is enabled, captcha will also be required on sign in. */ signUpIfMissing?: boolean; - /** - * The captcha token returned from the captcha challenge. - * - * @internal - */ - captchaToken?: string; - /** - * The captcha error if the captcha challenge failed. - * - * @internal - */ - captchaError?: unknown; - /** - * The type of captcha widget used ('smart', 'invisible', or null). - * - * @internal - */ - captchaWidgetType?: string | null; } export type SignInFuturePasswordParams = { From 2266545c1bbda1c6f701f0ad51b0f60014387c84 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Fri, 13 Feb 2026 13:36:17 -0500 Subject: [PATCH 11/12] chore: Fix typing issue --- packages/clerk-js/src/core/resources/SignIn.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index deb7dcc04e6..2e40da3c189 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -169,12 +169,12 @@ export class SignIn extends BaseResource implements SignInResource { create = async (params: SignInCreateParams): Promise => { debugLogger.debug('SignIn.create', { id: this.id, strategy: 'strategy' in params ? params.strategy : undefined }); - let finalParams = { ...params }; + let body: Record = { ...params }; // Inject browser locale const browserLocale = getBrowserLocale(); if (browserLocale) { - finalParams.locale = browserLocale; + body.locale = browserLocale; } if ( @@ -188,12 +188,12 @@ export class SignIn extends BaseResource implements SignInResource { if (!captchaParams) { throw new ClerkRuntimeError('', { code: 'captcha_unavailable' }); } - finalParams = { ...finalParams, ...captchaParams }; + body = { ...body, ...captchaParams }; } return this._basePost({ path: this.pathRoot, - body: finalParams, + body: body, }); }; From edc4fa14437fb4f1246d0ffaf47500d579bb1e33 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Fri, 13 Feb 2026 13:40:39 -0500 Subject: [PATCH 12/12] chore: Bump bundlewatch --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index c694488eb56..18b7da9307e 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -3,7 +3,7 @@ { "path": "./dist/clerk.js", "maxSize": "539KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "66KB" }, { "path": "./dist/clerk.chips.browser.js", "maxSize": "66KB" }, - { "path": "./dist/clerk.legacy.browser.js", "maxSize": "106KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "107KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "307KB" }, { "path": "./dist/clerk.native.js", "maxSize": "65KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" },