Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
77fe28c
feat(clerk-js): Trigger a new request to submit the captcha token
anagstef Jun 6, 2025
99f998b
feat(clerk-js): Enhance SignUp.create method to accept options
anagstef Jun 12, 2025
eb56ecd
fix(clerk-js): Introduce the `two_step_sign_up_create_enabled` FF in …
anagstef Jun 13, 2025
c87bab1
Merge branch 'main' into stefanos/fraud-761-clerkjs-split-the-signupc…
anagstef Jun 16, 2025
fe50d7d
Update changeset
anagstef Jun 16, 2025
c839759
Merge branch 'main' into stefanos/fraud-761-clerkjs-split-the-signupc…
anagstef Jun 18, 2025
90b4ba2
Update bundlewatch configuration for clerk-js
anagstef Jun 18, 2025
45ec3bd
Merge branch 'main' into stefanos/fraud-761-clerkjs-split-the-signupc…
anagstef Jun 18, 2025
4eafa6f
feat(clerk-js): Add challenge attribute and update challenge triggeri…
anagstef Jun 18, 2025
d045e31
Merge branch 'main' into stefanos/fraud-761-clerkjs-split-the-signupc…
anagstef Jun 18, 2025
56b9d53
chore(clerk-js): Update maxSize for clerk.browser.js in bundlewatch c…
anagstef Jun 18, 2025
0667427
refactor(clerk-js): Rename challenge attribute to captcha_challenge
anagstef Jun 19, 2025
3e0dbb4
Merge branch 'main' into stefanos/fraud-761-clerkjs-split-the-signupc…
anagstef Jun 19, 2025
701c920
chore(clerk-js): Update maxSize for clerk.headless.js in bundlewatch …
anagstef Jun 19, 2025
26affdf
Merge branch 'main' into stefanos/fraud-761-clerkjs-split-the-signupc…
anagstef Jun 24, 2025
aa783a5
feat(clerk-js): Solve the Captcha on the SignUpContinue screen if it'…
anagstef Jun 24, 2025
c7da4d3
fix(clerk-js): Update the second step on SignUp to include only the s…
anagstef Jun 25, 2025
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/dark-moons-sort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Trigger a new request to submit the captcha token on sign up when executing the `signUp.create` method.
6 changes: 6 additions & 0 deletions packages/clerk-js/src/core/resources/DisplayConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource
branded: boolean = false;
captchaHeartbeat: boolean = false;
captchaHeartbeatIntervalMs?: number;
twoStepSignUpCreateEnabled: boolean = false;
captchaOauthBypass: OAuthStrategy[] = ['oauth_google', 'oauth_microsoft', 'oauth_apple'];
captchaProvider: CaptchaProvider = 'turnstile';
captchaPublicKey: string | null = null;
Expand Down Expand Up @@ -80,6 +81,10 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource
this.applicationName = this.withDefault(data.application_name, this.applicationName);
this.branded = this.withDefault(data.branded, this.branded);
this.captchaHeartbeat = this.withDefault(data.captcha_heartbeat, this.captchaHeartbeat);
this.twoStepSignUpCreateEnabled = this.withDefault(
data.two_step_sign_up_create_enabled,
this.twoStepSignUpCreateEnabled,
);
this.captchaHeartbeatIntervalMs = this.withDefault(
data.captcha_heartbeat_interval_ms,
this.captchaHeartbeatIntervalMs,
Expand Down Expand Up @@ -130,6 +135,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource
branded: this.branded,
captcha_heartbeat_interval_ms: this.captchaHeartbeatIntervalMs,
captcha_heartbeat: this.captchaHeartbeat,
two_step_sign_up_create_enabled: this.twoStepSignUpCreateEnabled,
captcha_oauth_bypass: this.captchaOauthBypass,
captcha_provider: this.captchaProvider,
captcha_public_key_invisible: this.captchaPublicKeyInvisible,
Expand Down
69 changes: 61 additions & 8 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
PrepareVerificationParams,
PrepareWeb3WalletVerificationParams,
SignUpAuthenticateWithWeb3Params,
SignUpCreateOptions,
SignUpCreateParams,
SignUpField,
SignUpIdentificationField,
Expand Down Expand Up @@ -46,7 +47,7 @@ import {
clerkVerifyEmailAddressCalledBeforeCreate,
clerkVerifyWeb3WalletCalledBeforeCreate,
} from '../errors';
import { BaseResource, ClerkRuntimeError, SignUpVerifications } from './internal';
import { BaseResource, SignUpVerifications } from './internal';

declare global {
interface Window {
Expand Down Expand Up @@ -83,16 +84,23 @@ export class SignUp extends BaseResource implements SignUpResource {
this.fromJSON(data);
}

create = async (_params: SignUpCreateParams): Promise<SignUpResource> => {
create = async (_params: SignUpCreateParams, options?: SignUpCreateOptions): Promise<SignUpResource> => {
if (SignUp.clerk.__unstable__environment?.displayConfig?.twoStepSignUpCreateEnabled) {
return this.twoStepCreate(_params, options);
}

// This is the old flow and will be completely replaced by the two step flow when it's rolled out to everyone
return this.legacyCreate(_params);
};

private legacyCreate = async (_params: SignUpCreateParams): Promise<SignUpResource> => {
let params: Record<string, unknown> = _params;

if (!__BUILD_DISABLE_RHC__ && !this.clientBypass() && !this.shouldBypassCaptchaForAttempt(params)) {
const captchaChallenge = new CaptchaChallenge(SignUp.clerk);
const captchaParams = await captchaChallenge.managedOrInvisible({ action: 'signup' });
if (!captchaParams) {
throw new ClerkRuntimeError('', { code: 'captcha_unavailable' });
if (!this.shouldBypassCaptchaForAttempt(params)) {
const captchaParams = await this.getCaptchaParams();
if (captchaParams) {
params = { ...params, ...captchaParams };
}
params = { ...params, ...captchaParams };
}

if (params.transfer && this.shouldBypassCaptchaForAttempt(params)) {
Expand All @@ -105,6 +113,30 @@ export class SignUp extends BaseResource implements SignUpResource {
});
};

private twoStepCreate = async (
_params: SignUpCreateParams,
options?: SignUpCreateOptions,
): Promise<SignUpResource> => {
const params: Record<string, unknown> = _params;

// This is a legacy flow, where we allowed specific OAuth providers to bypass the captcha
// This is no longer supported, but we need to keep it for backwards compatibility
if (params.transfer && this.shouldBypassCaptchaForAttempt(params)) {
params.strategy = SignUp.clerk.client?.signIn.firstFactorVerification.strategy;
}

await this._basePost({
path: this.pathRoot,
body: normalizeUnsafeMetadata(params),
});

if (!this.shouldBypassCaptchaForAttempt(params) && !options?.skipChallenge) {
Copy link
Member

Choose a reason for hiding this comment

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

should whether challenge is a missing field factor into this check?

Copy link
Member Author

Choose a reason for hiding this comment

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

That's a good call actually! It will also help us in the future to get rid of the check if the captcha is enabled or not from the displayConfig. 🤔

return this.solveChallenge();
}

return this;
};

prepareVerification = (params: PrepareVerificationParams): Promise<this> => {
return this._basePost({
body: params,
Expand Down Expand Up @@ -438,12 +470,33 @@ export class SignUp extends BaseResource implements SignUpResource {
};
}

private solveChallenge = async (): Promise<SignUpResource> => {
const params = await this.getCaptchaParams();
if (params) {
return this.update(params);
}

return this;
};

private getCaptchaParams = async (): Promise<Record<string, unknown> | undefined> => {
let params: Record<string, unknown> | undefined;

if (!__BUILD_DISABLE_RHC__ && !this.clientBypass()) {
const captchaChallenge = new CaptchaChallenge(SignUp.clerk);
params = await captchaChallenge.managedOrInvisible({ action: 'signup' });
}

return params;
};

private clientBypass() {
return SignUp.clerk.client?.captchaBypass;
}

/**
* We delegate bot detection to the following providers, instead of relying on turnstile exclusively
* This is a legacy flow, where we allowed specific OAuth providers to bypass the captcha
*/
protected shouldBypassCaptchaForAttempt(params: SignUpCreateParams) {
if (!params.strategy) {
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,8 @@ function SignUpStartInternal(): JSX.Element {

// TODO: This is a hack to reset the sign in attempt so that the oauth error
// does not persist on full page reloads.
// We will revise this strategy as part of the Clerk DX epic.
void (await signUp.create({}));
// This will be handled by the backend (FAPI) in the future.
void (await signUp.create({}, { skipChallenge: true }));
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Remove redundant void (await …) wrapper

Inside an async function you can simply await the promise. The current form is harder to read and offers no benefit.

-        void (await signUp.create({}, { skipChallenge: true }));
+        await signUp.create({}, { skipChallenge: true });

If the result should be ignored but failures tolerated, consider wrapping in try/catch instead of swallowing silently.

🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx around lines 218
to 220, remove the redundant 'void (await ...)' wrapper and simply use 'await'
to handle the promise. If the intention is to ignore the result but still handle
potential errors, wrap the await call in a try/catch block instead of silently
swallowing any failures.

}

Expand Down
9 changes: 4 additions & 5 deletions packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,14 @@ export class CaptchaChallenge {
if (e.captchaError) {
return { captchaError: e.captchaError };
}
// if captcha action is signup, we return undefined, because we don't want to make the call to FAPI
return opts?.action === 'verify' ? { captchaError: e?.message || e || 'unexpected_captcha_error' } : undefined;
return { captchaError: e?.message || e || 'unexpected_captcha_error' };
});
return opts?.action === 'verify' ? { ...captchaResult, captchaAction: 'verify' } : captchaResult;
return { ...captchaResult, captchaAction: opts?.action };
}

// if captcha action is signup, we return an empty object, because it means that the bot protection is disabled
// if captcha action is signup, we return undefined, because it means that the bot protection is disabled
// and the user should be able to sign up without solving a captcha
return opts?.action === 'verify' ? { captchaError: 'captcha_unavailable', captchaAction: opts?.action } : {};
return opts?.action === 'verify' ? { captchaError: 'captcha_unavailable', captchaAction: opts?.action } : undefined;
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/displayConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface DisplayConfigJSON {
captcha_oauth_bypass: OAuthStrategy[] | null;
captcha_heartbeat?: boolean;
captcha_heartbeat_interval_ms?: number;
two_step_sign_up_create_enabled?: boolean;
home_url: string;
instance_environment_type: string;
logo_image_url: string;
Expand Down Expand Up @@ -69,6 +70,7 @@ export interface DisplayConfigResource extends ClerkResource {
captchaOauthBypass: OAuthStrategy[];
captchaHeartbeat: boolean;
captchaHeartbeatIntervalMs?: number;
twoStepSignUpCreateEnabled?: boolean;
homeUrl: string;
instanceEnvironmentType: string;
logoImageUrl: string;
Expand Down
6 changes: 5 additions & 1 deletion packages/types/src/signUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export interface SignUpResource extends ClerkResource {
abandonAt: number | null;
legalAcceptedAt: number | null;

create: (params: SignUpCreateParams) => Promise<SignUpResource>;
create: (params: SignUpCreateParams, options?: SignUpCreateOptions) => Promise<SignUpResource>;

update: (params: SignUpUpdateParams) => Promise<SignUpResource>;

Expand Down Expand Up @@ -199,6 +199,10 @@ export type SignUpCreateParams = Partial<
} & Omit<SnakeToCamel<Record<SignUpAttributeField | SignUpVerifiableField, string>>, 'legalAccepted'>
>;

export type SignUpCreateOptions = Partial<{
skipChallenge: boolean;
}>;

export type SignUpUpdateParams = SignUpCreateParams;

/**
Expand Down