Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/strict-needles-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': minor
'@clerk/shared': minor
---

Support `sign_up_if_missing` on SignIn.create, including captcha
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
118 changes: 114 additions & 4 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
_futureAuthenticateWithPopup,
wrapWithPopupRoutes,
} from '../../utils/authenticateWithPopup';
import { CaptchaChallenge } from '../../utils/captcha/CaptchaChallenge';
import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask';
import { loadZxcvbn } from '../../utils/zxcvbn';
import {
Expand Down Expand Up @@ -162,12 +163,37 @@ export class SignIn extends BaseResource implements SignInResource {
this.fromJSON(data);
}

create = (params: SignInCreateParams): Promise<SignInResource> => {
create = async (params: SignInCreateParams): Promise<SignInResource> => {
debugLogger.debug('SignIn.create', { id: this.id, strategy: 'strategy' in params ? params.strategy : undefined });
const locale = getBrowserLocale();

let finalParams = { ...params };

// 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 captchaParams = await this.getCaptchaToken();
finalParams = { ...finalParams, ...captchaParams };
}

if (params.transfer && this.shouldBypassCaptchaForAttempt(params)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check guards against an attacker who tries to make a fake transfer and then changes strategy at the same time, which would be an attempt to get around WAF rules and Captcha rules that exclude transfers. We already guard against this on the backend on both sign in and sign up, but in the SDK we were only checking it on sign up. So I took this opportunity to also add the check on sign in.

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<SignInCreateParams, { transfer: true }>;
(finalParams as TransferParams).strategy = strategy as TransferParams['strategy'];
}
}
Comment on lines +185 to +192
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential null reference when accessing signUp properties during transfer.

Line 186 uses optional chaining on client? but not on the subsequent property chain. If client exists but signUp, verifications, or externalAccount is null/undefined, this will throw a TypeError.

Proposed fix
     if (params.transfer && this.shouldBypassCaptchaForAttempt(params)) {
-      const strategy = SignIn.clerk.client?.signUp.verifications.externalAccount.strategy;
+      const strategy = SignIn.clerk.client?.signUp?.verifications?.externalAccount?.strategy;
       if (strategy) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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<SignInCreateParams, { transfer: true }>;
(finalParams as TransferParams).strategy = strategy as TransferParams['strategy'];
}
}
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<SignInCreateParams, { transfer: true }>;
(finalParams as TransferParams).strategy = strategy as TransferParams['strategy'];
}
}
🤖 Prompt for AI Agents
In `@packages/clerk-js/src/core/resources/SignIn.ts` around lines 185 - 192, The
code accesses nested properties on SignIn.clerk.client without guarding
signUp/verifications/externalAccount; change the retrieval of strategy in the
SignIn transfer branch to use safe checks (e.g. optional chaining or explicit
null checks) so you do const strategy =
SignIn.clerk.client?.signUp?.verifications?.externalAccount?.strategy and only
assign (finalParams as TransferParams).strategy = strategy as
TransferParams['strategy'] if strategy is not undefined/null; update the logic
inside the transfer block (related to shouldBypassCaptchaForAttempt and
finalParams assignment) to avoid any potential TypeError from missing
intermediate objects.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's a sign in and a transfer and there's no signup or verification or externalaccount, that would be an error.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dmoerner, thank you for the clarification. You're right—in this transfer flow context, if those nested properties are missing, that would indicate a legitimate error state that should surface rather than be silently handled. The current implementation with optional chaining only on client is appropriate here.


return this._basePost({
path: this.pathRoot,
body: locale ? { locale, ...params } : params,
body: finalParams,
});
};

Expand Down Expand Up @@ -574,6 +600,76 @@ 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: { 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,
)
) {
return true;
}
Comment on lines +621 to +629
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same null reference risk in the transfer branch.

Line 625 accesses signUp.verifications.externalAccount.strategy without optional chaining. This could throw if the signUp state is incomplete during a transfer scenario.

Proposed fix
     if (
       params.transfer &&
       captchaOauthBypass.some(
         // eslint-disable-next-line `@typescript-eslint/no-non-null-assertion`
-        strategy => strategy === SignIn.clerk.client!.signUp.verifications.externalAccount.strategy,
+        strategy => strategy === SignIn.clerk.client?.signUp?.verifications?.externalAccount?.strategy,
       )
     ) {
🤖 Prompt for AI Agents
In `@packages/clerk-js/src/core/resources/SignIn.ts` around lines 621 - 629, The
transfer branch accesses
SignIn.clerk.client!.signUp.verifications.externalAccount.strategy directly
which can throw if signUp/verifications/externalAccount is undefined; update the
condition inside the captchaOauthBypass check to use optional chaining (e.g.,
SignIn.clerk.client?.signUp?.verifications?.externalAccount?.strategy) and
compare safely (guarding null/undefined) so the predicate returns false when the
nested property is missing, ensuring the transfer path doesn't cause a runtime
error.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As in the other case, a transfer with a sign up that's incomplete in the sense of missing an external account is an error.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dmoerner, understood. If the external account is expected to exist during transfer scenarios, the assertion is appropriate to catch that error early. Thanks for the clarification.


return false;
}

/**
* Determines whether captcha is required based on the provided params.
*/
private shouldRequireCaptcha(params: {
strategy?: string;
transfer?: boolean;
sign_up_if_missing?: boolean;
}): 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;
}

/**
* 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',
Expand Down Expand Up @@ -758,10 +854,24 @@ class SignInFuture implements SignInFutureResource {
}

private async _create(params: SignInFutureCreateParams): Promise<void> {
let body = { ...params };

const locale = getBrowserLocale();
if (locale) {
body.locale = locale;
}

// Determine captcha requirement based on params
const requiresCaptcha = this.#resource['shouldRequireCaptcha'](body);

if (requiresCaptcha) {
const captchaParams = await this.#resource['getCaptchaToken']();
body = { ...body, ...captchaParams };
}

await this.#resource.__internal_basePost({
path: this.#resource.pathRoot,
body: locale ? { locale, ...params } : params,
body,
});
}

Expand Down
Loading
Loading