Skip to content

feat(clerk-js): Trigger a new request to submit the captcha token #6076

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
77 changes: 68 additions & 9 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ import type {
PrepareVerificationParams,
PrepareWeb3WalletVerificationParams,
SignUpAuthenticateWithWeb3Params,
SignUpCreateOptions,
SignUpCreateParams,
SignUpField,
SignUpIdentificationField,
SignUpJSON,
SignUpJSONSnapshot,
SignUpResource,
SignUpStatus,
SignUpUpdateOptions,
SignUpUpdateParams,
StartEmailLinkFlowParams,
Web3Provider,
Expand All @@ -45,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 @@ -82,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.solveCaptchaChallenge();
if (captchaParams) {
params = { ...params, ...captchaParams };
}
params = { ...params, ...captchaParams };
}

if (params.transfer && this.shouldBypassCaptchaForAttempt(params)) {
Expand All @@ -104,6 +113,33 @@ 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?.skipCaptchaChallenge) {
return this.update(
{ strategy: params.strategy as SignUpUpdateParams['strategy'] },
{ triggerCaptchaChallenge: true },
);
}

return this;
};

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

update = (params: SignUpUpdateParams): Promise<SignUpResource> => {
update = async (params: SignUpUpdateParams, options?: SignUpUpdateOptions): Promise<SignUpResource> => {
const isCaptchaChallengeMissingAndRequired =
this.missingFields.some(field => field === 'captcha_challenge') &&
this.requiredFields.some(field => field === 'captcha_challenge');

if (options?.triggerCaptchaChallenge && isCaptchaChallengeMissingAndRequired) {
const captchaParams = await this.solveCaptchaChallenge();
if (captchaParams) {
params = { ...params, ...captchaParams };
}
}

return this._basePatch({
body: normalizeUnsafeMetadata(params),
});
Expand Down Expand Up @@ -417,12 +464,24 @@ export class SignUp extends BaseResource implements SignUpResource {
};
}

private solveCaptchaChallenge = 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
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ function SignUpContinueInternal() {
card.setError(undefined);

return signUp
.update(buildRequest(fieldsToSubmit))
.update(buildRequest(fieldsToSubmit), { triggerCaptchaChallenge: true })
.then(res =>
completeSignUpFlow({
signUp: res,
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 @@ -220,8 +220,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({}, { skipCaptchaChallenge: true }));
}
}

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
1 change: 1 addition & 0 deletions packages/types/src/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export type FirstNameAttribute = 'first_name';
export type LastNameAttribute = 'last_name';
export type PasswordAttribute = 'password';
export type LegalAcceptedAttribute = 'legal_accepted';
export type CaptchaChallengeAttribute = 'captcha_challenge';
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
27 changes: 23 additions & 4 deletions packages/types/src/signUp.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import type { PhoneCodeChannel } from 'phoneCodeChannel';

import type { FirstNameAttribute, LastNameAttribute, LegalAcceptedAttribute, PasswordAttribute } from './attributes';
import type {
CaptchaChallengeAttribute,
FirstNameAttribute,
LastNameAttribute,
LegalAcceptedAttribute,
PasswordAttribute,
} from './attributes';
import type { AttemptEmailAddressVerificationParams, PrepareEmailAddressVerificationParams } from './emailAddress';
import type {
EmailAddressIdentifier,
Expand Down Expand Up @@ -71,9 +77,9 @@ 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>;
update: (params: SignUpUpdateParams, options?: SignUpUpdateOptions) => Promise<SignUpResource>;

upsert: (params: SignUpCreateParams | SignUpUpdateParams) => Promise<SignUpResource>;

Expand Down Expand Up @@ -160,7 +166,12 @@ export type AttemptVerificationParams =
signature: string;
};

export type SignUpAttributeField = FirstNameAttribute | LastNameAttribute | PasswordAttribute | LegalAcceptedAttribute;
export type SignUpAttributeField =
| FirstNameAttribute
| LastNameAttribute
| PasswordAttribute
| LegalAcceptedAttribute
| CaptchaChallengeAttribute;

// TODO: SignUpVerifiableField or SignUpIdentifier?
export type SignUpVerifiableField =
Expand Down Expand Up @@ -199,6 +210,14 @@ export type SignUpCreateParams = Partial<
} & Omit<SnakeToCamel<Record<SignUpAttributeField | SignUpVerifiableField, string>>, 'legalAccepted'>
>;

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

export type SignUpUpdateOptions = Partial<{
triggerCaptchaChallenge: boolean;
}>;

export type SignUpUpdateParams = SignUpCreateParams;

/**
Expand Down
Loading