diff --git a/.changeset/protect-check-support.md b/.changeset/protect-check-support.md
new file mode 100644
index 00000000000..4c50e840b92
--- /dev/null
+++ b/.changeset/protect-check-support.md
@@ -0,0 +1,21 @@
+---
+'@clerk/clerk-js': minor
+'@clerk/localizations': minor
+'@clerk/shared': minor
+'@clerk/ui': minor
+---
+
+Add support for Clerk Protect mid-flow SDK challenges (`protect_check`) on both sign-up and sign-in.
+
+When the Protect antifraud service issues a challenge, responses now carry a `protectCheck` field
+with `{ status, token, sdkUrl, expiresAt?, uiHints? }`. Clients resolve the gate by loading the
+SDK at `sdkUrl`, executing the challenge, and submitting the resulting proof token via
+`signUp.submitProtectCheck({ proofToken })` or `signIn.submitProtectCheck({ proofToken })`. The
+response may carry a chained challenge, which the SDK resolves iteratively.
+
+Sign-in adds a new `'needs_protect_check'` value to the `SignInStatus` union, surfaced when the
+server-side SDK-version gate is enabled. Clients should treat the `protectCheck` field as the
+authoritative gate signal and fall back to the status value for defense in depth.
+
+The pre-built `` and `` components handle the gate automatically by routing
+to a new `protect-check` route that runs the challenge SDK and resumes the flow on completion.
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index 4663e15eafe..50b61a38505 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -2241,6 +2241,7 @@ export class Clerk implements ClerkInterface {
firstFactorVerificationErrorCode: firstFactorVerification.error?.code,
firstFactorVerificationSessionId: firstFactorVerification.error?.meta?.sessionId,
sessionId: signIn.createdSessionId,
+ protectCheck: signIn.protectCheck,
};
const makeNavigate = (to: string) => () => navigate(to);
@@ -2264,6 +2265,10 @@ export class Clerk implements ClerkInterface {
buildURL({ base: displayConfig.signInUrl, hashPath: '/reset-password' }, { stringify: true }),
);
+ const navigateToSignInProtectCheck = makeNavigate(
+ buildURL({ base: displayConfig.signInUrl, hashPath: '/protect-check' }, { stringify: true }),
+ );
+
const redirectUrls = new RedirectUrls(this.#options, params);
const navigateToContinueSignUp = makeNavigate(
@@ -2296,6 +2301,7 @@ export class Clerk implements ClerkInterface {
verifyPhonePath:
params.verifyPhoneNumberUrl ||
buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-phone-number' }, { stringify: true }),
+ protectCheckPath: buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }),
navigate,
});
};
@@ -2332,11 +2338,20 @@ export class Clerk implements ClerkInterface {
});
}
+ // Per Protect spec §4.4: OAuth/SAML callbacks can result in a protect_check gate that
+ // surfaces on the next /v1/client read. Honor either the field or the status override.
+ if (si.protectCheck || si.status === 'needs_protect_check') {
+ return navigateToSignInProtectCheck();
+ }
+
const userExistsButNeedsToSignIn =
su.externalAccountStatus === 'transferable' && su.externalAccountErrorCode === 'external_account_exists';
if (userExistsButNeedsToSignIn) {
const res = await signIn.create({ transfer: true });
+ if (res.protectCheck || res.status === 'needs_protect_check') {
+ return navigateToSignInProtectCheck();
+ }
switch (res.status) {
case 'complete':
return this.setActive({
diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts
index f6540d06eb9..d1ecd03a0f3 100644
--- a/packages/clerk-js/src/core/resources/SignIn.ts
+++ b/packages/clerk-js/src/core/resources/SignIn.ts
@@ -31,6 +31,7 @@ import type {
PhoneCodeFactor,
PrepareFirstFactorParams,
PrepareSecondFactorParams,
+ ProtectCheckResource,
ResetPasswordEmailCodeFactorConfig,
ResetPasswordParams,
ResetPasswordPhoneCodeFactorConfig,
@@ -112,6 +113,7 @@ export class SignIn extends BaseResource implements SignInResource {
createdSessionId: string | null = null;
userData: UserData = new UserData(null);
clientTrustState?: ClientTrustState;
+ protectCheck: ProtectCheckResource | null = null;
/**
* The current status of the sign-in process.
@@ -153,6 +155,14 @@ export class SignIn extends BaseResource implements SignInResource {
*/
__internal_basePost = this._basePost.bind(this);
+ /**
+ * @internal Only used for internal purposes, and is not intended to be used directly.
+ *
+ * This property is used to provide access to underlying Client methods to `SignInFuture`, which wraps an instance
+ * of `SignIn`.
+ */
+ __internal_basePatch = this._basePatch.bind(this);
+
/**
* @internal Only used for internal purposes, and is not intended to be used directly.
*
@@ -257,6 +267,14 @@ export class SignIn extends BaseResource implements SignInResource {
});
};
+ submitProtectCheck = (params: { proofToken: string }): Promise => {
+ debugLogger.debug('SignIn.submitProtectCheck', { id: this.id });
+ return this._basePatch({
+ action: 'protect_check',
+ body: { proof_token: params.proofToken },
+ });
+ };
+
attemptFirstFactor = (params: AttemptFirstFactorParams): Promise => {
debugLogger.debug('SignIn.attemptFirstFactor', { id: this.id, strategy: params.strategy });
let config;
@@ -594,6 +612,15 @@ export class SignIn extends BaseResource implements SignInResource {
this.createdSessionId = data.created_session_id;
this.userData = new UserData(data.user_data);
this.clientTrustState = data.client_trust_state ?? undefined;
+ this.protectCheck = data.protect_check
+ ? {
+ status: data.protect_check.status,
+ token: data.protect_check.token,
+ sdkUrl: data.protect_check.sdk_url,
+ expiresAt: data.protect_check.expires_at,
+ uiHints: data.protect_check.ui_hints,
+ }
+ : null;
}
eventBus.emit('resource:update', { resource: this });
@@ -654,6 +681,15 @@ export class SignIn extends BaseResource implements SignInResource {
identifier: this.identifier,
created_session_id: this.createdSessionId,
user_data: this.userData.__internal_toSnapshot(),
+ protect_check: this.protectCheck
+ ? {
+ status: this.protectCheck.status,
+ token: this.protectCheck.token,
+ sdk_url: this.protectCheck.sdkUrl,
+ ...(this.protectCheck.expiresAt !== undefined && { expires_at: this.protectCheck.expiresAt }),
+ ...(this.protectCheck.uiHints !== undefined && { ui_hints: this.protectCheck.uiHints }),
+ }
+ : null,
};
}
}
@@ -783,6 +819,19 @@ class SignInFuture implements SignInFutureResource {
return this.#resource.secondFactorVerification;
}
+ get protectCheck() {
+ return this.#resource.protectCheck;
+ }
+
+ async submitProtectCheck(params: { proofToken: string }): Promise<{ error: ClerkError | null }> {
+ return runAsyncResourceTask(this.#resource, async () => {
+ await this.#resource.__internal_basePatch({
+ action: 'protect_check',
+ body: { proof_token: params.proofToken },
+ });
+ });
+ }
+
get canBeDiscarded() {
return this.#canBeDiscarded;
}
diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts
index d0c884509ed..f8c7204ad6a 100644
--- a/packages/clerk-js/src/core/resources/SignUp.ts
+++ b/packages/clerk-js/src/core/resources/SignUp.ts
@@ -17,6 +17,7 @@ import type {
PreparePhoneNumberVerificationParams,
PrepareVerificationParams,
PrepareWeb3WalletVerificationParams,
+ ProtectCheckResource,
SignUpAuthenticateWithSolanaParams,
SignUpAuthenticateWithWeb3Params,
SignUpCreateParams,
@@ -92,6 +93,7 @@ export class SignUp extends BaseResource implements SignUpResource {
externalAccount: any;
hasPassword = false;
unsafeMetadata: SignUpUnsafeMetadata = {};
+ protectCheck: ProtectCheckResource | null = null;
createdSessionId: string | null = null;
createdUserId: string | null = null;
abandonAt: number | null = null;
@@ -195,6 +197,14 @@ export class SignUp extends BaseResource implements SignUpResource {
});
};
+ submitProtectCheck = (params: { proofToken: string }): Promise => {
+ debugLogger.debug('SignUp.submitProtectCheck', { id: this.id });
+ return this._basePatch({
+ action: 'protect_check',
+ body: { proof_token: params.proofToken },
+ });
+ };
+
prepareEmailAddressVerification = (params?: PrepareEmailAddressVerificationParams): Promise => {
return this.prepareVerification(params || { strategy: 'email_code' });
};
@@ -495,6 +505,15 @@ export class SignUp extends BaseResource implements SignUpResource {
this.missingFields = data.missing_fields;
this.unverifiedFields = data.unverified_fields;
this.verifications = new SignUpVerifications(data.verifications);
+ this.protectCheck = data.protect_check
+ ? {
+ status: data.protect_check.status,
+ token: data.protect_check.token,
+ sdkUrl: data.protect_check.sdk_url,
+ expiresAt: data.protect_check.expires_at,
+ uiHints: data.protect_check.ui_hints,
+ }
+ : null;
this.username = data.username;
this.firstName = data.first_name;
this.lastName = data.last_name;
@@ -528,6 +547,15 @@ export class SignUp extends BaseResource implements SignUpResource {
missing_fields: this.missingFields,
unverified_fields: this.unverifiedFields,
verifications: this.verifications.__internal_toSnapshot(),
+ protect_check: this.protectCheck
+ ? {
+ status: this.protectCheck.status,
+ token: this.protectCheck.token,
+ sdk_url: this.protectCheck.sdkUrl,
+ ...(this.protectCheck.expiresAt !== undefined && { expires_at: this.protectCheck.expiresAt }),
+ ...(this.protectCheck.uiHints !== undefined && { ui_hints: this.protectCheck.uiHints }),
+ }
+ : null,
username: this.username,
first_name: this.firstName,
last_name: this.lastName,
@@ -778,6 +806,10 @@ class SignUpFuture implements SignUpFutureResource {
return this.#resource.unverifiedFields;
}
+ get protectCheck() {
+ return this.#resource.protectCheck;
+ }
+
get isTransferable() {
// TODO: we can likely remove the error code check as the status should be sufficient
return (
@@ -1133,6 +1165,15 @@ class SignUpFuture implements SignUpFutureResource {
});
}
+ async submitProtectCheck(params: { proofToken: string }): Promise<{ error: ClerkError | null }> {
+ return runAsyncResourceTask(this.#resource, async () => {
+ await this.#resource.__internal_basePatch({
+ action: 'protect_check',
+ body: { proof_token: params.proofToken },
+ });
+ });
+ }
+
async ticket(params?: SignUpFutureTicketParams): Promise<{ error: ClerkError | null }> {
const ticket = params?.ticket ?? getClerkQueryParam('__clerk_ticket');
return this.create({ ...params, ticket: ticket ?? undefined });
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 76d82b08de8..31ca4fbb72f 100644
--- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
+++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
@@ -2432,4 +2432,171 @@ describe('SignIn', () => {
});
});
});
+
+ describe('protectCheck', () => {
+ it('deserializes protect_check from JSON', () => {
+ const signIn = new SignIn({
+ id: 'signin_123',
+ object: 'sign_in',
+ status: 'needs_protect_check',
+ supported_identifiers: [],
+ identifier: 'user@example.com',
+ user_data: {} as any,
+ supported_first_factors: [],
+ supported_second_factors: [],
+ first_factor_verification: null,
+ second_factor_verification: null,
+ created_session_id: null,
+ protect_check: {
+ status: 'pending',
+ token: 'challenge-token-abc',
+ sdk_url: 'https://sdk.example.com/challenge.js',
+ expires_at: 1741564800000,
+ ui_hints: { theme: 'dark' },
+ },
+ });
+
+ expect(signIn.status).toBe('needs_protect_check');
+ expect(signIn.protectCheck?.status).toBe('pending');
+ expect(signIn.protectCheck?.token).toBe('challenge-token-abc');
+ expect(signIn.protectCheck?.sdkUrl).toBe('https://sdk.example.com/challenge.js');
+ expect(signIn.protectCheck?.expiresAt).toBe(1741564800000);
+ expect(signIn.protectCheck?.uiHints).toEqual({ theme: 'dark' });
+ });
+
+ it('sets protectCheck to null when not present in JSON', () => {
+ const signIn = new SignIn({
+ id: 'signin_123',
+ object: 'sign_in',
+ status: 'needs_first_factor',
+ supported_identifiers: [],
+ identifier: 'user@example.com',
+ user_data: {} as any,
+ supported_first_factors: [],
+ supported_second_factors: [],
+ first_factor_verification: null,
+ second_factor_verification: null,
+ created_session_id: null,
+ } as any);
+
+ expect(signIn.protectCheck).toBeNull();
+ });
+
+ it('handles protect_check with optional fields omitted', () => {
+ const signIn = new SignIn({
+ id: 'signin_123',
+ object: 'sign_in',
+ status: 'needs_protect_check',
+ supported_identifiers: [],
+ identifier: 'user@example.com',
+ user_data: {} as any,
+ supported_first_factors: [],
+ supported_second_factors: [],
+ first_factor_verification: null,
+ second_factor_verification: null,
+ created_session_id: null,
+ protect_check: {
+ status: 'pending',
+ token: 'minimal-token',
+ sdk_url: 'https://example.com/sdk.js',
+ },
+ });
+
+ expect(signIn.protectCheck?.expiresAt).toBeUndefined();
+ expect(signIn.protectCheck?.uiHints).toBeUndefined();
+
+ const snapshot = signIn.__internal_toSnapshot();
+ expect(snapshot.protect_check).toEqual({
+ status: 'pending',
+ token: 'minimal-token',
+ sdk_url: 'https://example.com/sdk.js',
+ });
+ });
+
+ it('round-trips protectCheck through snapshot', () => {
+ const signIn = new SignIn({
+ id: 'signin_123',
+ object: 'sign_in',
+ status: 'needs_protect_check',
+ supported_identifiers: [],
+ identifier: 'user@example.com',
+ user_data: {} as any,
+ supported_first_factors: [],
+ supported_second_factors: [],
+ first_factor_verification: null,
+ second_factor_verification: null,
+ created_session_id: null,
+ protect_check: {
+ status: 'pending',
+ token: 'test-token',
+ sdk_url: 'https://example.com/sdk.js',
+ expires_at: 1700000000000,
+ ui_hints: {},
+ },
+ });
+
+ const snapshot = signIn.__internal_toSnapshot();
+ expect(snapshot.protect_check).toEqual({
+ status: 'pending',
+ token: 'test-token',
+ sdk_url: 'https://example.com/sdk.js',
+ expires_at: 1700000000000,
+ ui_hints: {},
+ });
+
+ const signIn2 = new SignIn(snapshot);
+ expect(signIn2.protectCheck?.token).toBe('test-token');
+ });
+
+ it('calls _basePatch with correct params for submitProtectCheck', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ client: null,
+ response: {
+ id: 'signin_123',
+ object: 'sign_in',
+ status: 'needs_first_factor',
+ supported_identifiers: [],
+ identifier: 'user@example.com',
+ user_data: {},
+ supported_first_factors: [],
+ supported_second_factors: [],
+ first_factor_verification: null,
+ second_factor_verification: null,
+ created_session_id: null,
+ protect_check: null,
+ },
+ });
+ BaseResource._fetch = mockFetch;
+
+ const signIn = new SignIn({
+ id: 'signin_123',
+ object: 'sign_in',
+ status: 'needs_protect_check',
+ supported_identifiers: [],
+ identifier: 'user@example.com',
+ user_data: {} as any,
+ supported_first_factors: [],
+ supported_second_factors: [],
+ first_factor_verification: null,
+ second_factor_verification: null,
+ created_session_id: null,
+ protect_check: {
+ status: 'pending',
+ token: 'challenge-token',
+ sdk_url: 'https://example.com/sdk.js',
+ },
+ });
+
+ const result = await signIn.submitProtectCheck({ proofToken: 'proof-abc' });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'PATCH',
+ body: { proof_token: 'proof-abc' },
+ }),
+ );
+ expect(result.status).toBe('needs_first_factor');
+ expect(result.protectCheck).toBeNull();
+ });
+ });
});
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 96257b65b73..dd5262c8850 100644
--- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts
+++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts
@@ -1356,4 +1356,238 @@ describe('SignUp', () => {
});
});
});
+
+ describe('protectCheck', () => {
+ it('deserializes protect_check from JSON', () => {
+ const signUp = new SignUp({
+ id: 'signup_123',
+ object: 'sign_up',
+ status: 'missing_requirements',
+ required_fields: [],
+ optional_fields: [],
+ missing_fields: ['protect_check'],
+ unverified_fields: [],
+ username: null,
+ first_name: null,
+ last_name: null,
+ email_address: 'test@example.com',
+ phone_number: null,
+ web3_wallet: null,
+ external_account_strategy: null,
+ external_account: null,
+ has_password: false,
+ unsafe_metadata: {},
+ created_session_id: null,
+ created_user_id: null,
+ abandon_at: null,
+ legal_accepted_at: null,
+ locale: null,
+ verifications: null,
+ protect_check: {
+ status: 'pending',
+ token: 'challenge-token-abc',
+ sdk_url: 'https://sdk.example.com/challenge.js',
+ expires_at: 1741564800000,
+ ui_hints: { theme: 'dark' },
+ },
+ });
+
+ expect(signUp.protectCheck).not.toBeNull();
+ expect(signUp.protectCheck?.status).toBe('pending');
+ expect(signUp.protectCheck?.token).toBe('challenge-token-abc');
+ expect(signUp.protectCheck?.sdkUrl).toBe('https://sdk.example.com/challenge.js');
+ expect(signUp.protectCheck?.expiresAt).toBe(1741564800000);
+ expect(signUp.protectCheck?.uiHints).toEqual({ theme: 'dark' });
+ expect(signUp.missingFields).toContain('protect_check');
+ });
+
+ it('handles protect_check with optional expires_at and ui_hints omitted', () => {
+ const signUp = new SignUp({
+ id: 'signup_123',
+ object: 'sign_up',
+ status: 'missing_requirements',
+ required_fields: [],
+ optional_fields: [],
+ missing_fields: ['protect_check'],
+ unverified_fields: [],
+ username: null,
+ first_name: null,
+ last_name: null,
+ email_address: null,
+ phone_number: null,
+ web3_wallet: null,
+ external_account_strategy: null,
+ external_account: null,
+ has_password: false,
+ unsafe_metadata: {},
+ created_session_id: null,
+ created_user_id: null,
+ abandon_at: null,
+ legal_accepted_at: null,
+ locale: null,
+ verifications: null,
+ protect_check: {
+ status: 'pending',
+ token: 'minimal-token',
+ sdk_url: 'https://example.com/sdk.js',
+ },
+ });
+
+ expect(signUp.protectCheck?.status).toBe('pending');
+ expect(signUp.protectCheck?.token).toBe('minimal-token');
+ expect(signUp.protectCheck?.expiresAt).toBeUndefined();
+ expect(signUp.protectCheck?.uiHints).toBeUndefined();
+
+ // Snapshot omits the optional fields when absent
+ const snapshot = signUp.__internal_toSnapshot();
+ expect(snapshot.protect_check).toEqual({
+ status: 'pending',
+ token: 'minimal-token',
+ sdk_url: 'https://example.com/sdk.js',
+ });
+ });
+
+ it('sets protectCheck to null when not present in JSON', () => {
+ const signUp = new SignUp({
+ id: 'signup_123',
+ object: 'sign_up',
+ status: 'missing_requirements',
+ required_fields: [],
+ optional_fields: [],
+ missing_fields: [],
+ unverified_fields: [],
+ username: null,
+ first_name: null,
+ last_name: null,
+ email_address: null,
+ phone_number: null,
+ web3_wallet: null,
+ external_account_strategy: null,
+ external_account: null,
+ has_password: false,
+ unsafe_metadata: {},
+ created_session_id: null,
+ created_user_id: null,
+ abandon_at: null,
+ legal_accepted_at: null,
+ locale: null,
+ verifications: null,
+ protect_check: null,
+ });
+
+ expect(signUp.protectCheck).toBeNull();
+ });
+
+ it('round-trips protectCheck through snapshot', () => {
+ const signUp = new SignUp({
+ id: 'signup_123',
+ object: 'sign_up',
+ status: 'missing_requirements',
+ required_fields: [],
+ optional_fields: [],
+ missing_fields: ['protect_check'],
+ unverified_fields: [],
+ username: null,
+ first_name: null,
+ last_name: null,
+ email_address: null,
+ phone_number: null,
+ web3_wallet: null,
+ external_account_strategy: null,
+ external_account: null,
+ has_password: false,
+ unsafe_metadata: {},
+ created_session_id: null,
+ created_user_id: null,
+ abandon_at: null,
+ legal_accepted_at: null,
+ locale: null,
+ verifications: null,
+ protect_check: {
+ status: 'pending',
+ token: 'test-token',
+ sdk_url: 'https://example.com/sdk.js',
+ expires_at: 1700000000000,
+ ui_hints: {},
+ },
+ });
+
+ const snapshot = signUp.__internal_toSnapshot();
+ expect(snapshot.protect_check).toEqual({
+ status: 'pending',
+ token: 'test-token',
+ sdk_url: 'https://example.com/sdk.js',
+ expires_at: 1700000000000,
+ ui_hints: {},
+ });
+
+ // Re-create from snapshot
+ const signUp2 = new SignUp(snapshot);
+ expect(signUp2.protectCheck?.token).toBe('test-token');
+ });
+
+ it('calls _basePatch with correct params for submitProtectCheck', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ client: null,
+ response: {
+ id: 'signup_123',
+ object: 'sign_up',
+ status: 'complete',
+ required_fields: [],
+ optional_fields: [],
+ missing_fields: [],
+ unverified_fields: [],
+ verifications: null,
+ protect_check: null,
+ created_session_id: 'sess_123',
+ created_user_id: 'user_123',
+ },
+ });
+ BaseResource._fetch = mockFetch;
+
+ const signUp = new SignUp({
+ id: 'signup_123',
+ object: 'sign_up',
+ status: 'missing_requirements',
+ required_fields: [],
+ optional_fields: [],
+ missing_fields: ['protect_check'],
+ unverified_fields: [],
+ username: null,
+ first_name: null,
+ last_name: null,
+ email_address: null,
+ phone_number: null,
+ web3_wallet: null,
+ external_account_strategy: null,
+ external_account: null,
+ has_password: false,
+ unsafe_metadata: {},
+ created_session_id: null,
+ created_user_id: null,
+ abandon_at: null,
+ legal_accepted_at: null,
+ locale: null,
+ verifications: null,
+ protect_check: {
+ status: 'pending',
+ token: 'challenge-token',
+ sdk_url: 'https://example.com/sdk.js',
+ expires_at: 1700000000000,
+ ui_hints: {},
+ },
+ });
+
+ const result = await signUp.submitProtectCheck({ proofToken: 'proof-abc' });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'PATCH',
+ body: { proof_token: 'proof-abc' },
+ }),
+ );
+ expect(result.status).toBe('complete');
+ expect(result.protectCheck).toBeNull();
+ });
+ });
});
diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts
index 3b5b3e1bc61..01cfdebe3ff 100644
--- a/packages/localizations/src/en-US.ts
+++ b/packages/localizations/src/en-US.ts
@@ -803,6 +803,11 @@ export const enUS: LocalizationResource = {
subtitle: 'Select a wallet below to sign in',
title: 'Sign in with Solana',
},
+ protectCheck: {
+ loading: 'Loading…',
+ subtitle: 'Please wait while we verify your request.',
+ title: 'Verifying your request',
+ },
},
signInEnterPasswordTitle: 'Enter your password',
signUp: {
@@ -899,6 +904,11 @@ export const enUS: LocalizationResource = {
subtitle: 'Select a wallet below to sign up',
title: 'Sign up with Solana',
},
+ protectCheck: {
+ loading: 'Loading…',
+ subtitle: 'Please wait while we verify your request.',
+ title: 'Verifying your request',
+ },
},
socialButtonsBlockButton: 'Continue with {{provider|titleize}}',
socialButtonsBlockButtonManyInView: '{{provider|titleize}}',
@@ -1019,6 +1029,7 @@ export const enUS: LocalizationResource = {
api_key_name_already_exists: 'API Key name already exists.',
api_key_usage_exceeded: 'You have reached your usage limit. You can remove the limit by upgrading to a paid plan.',
avatar_file_size_exceeded: 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.',
+ action_blocked: "This action couldn't be completed. Please try again later or contact support if this persists.",
avatar_file_type_invalid: 'File type not supported. Please upload a JPG, PNG, GIF, or WEBP image.',
captcha_invalid: undefined,
captcha_unavailable:
@@ -1084,6 +1095,13 @@ export const enUS: LocalizationResource = {
sentencePrefix: 'Your password must contain',
},
phone_number_exists: undefined,
+ protect_check_aborted: undefined,
+ protect_check_already_resolved: undefined,
+ protect_check_execution_failed: "Verification didn't complete. Please try again.",
+ protect_check_invalid_script: "Couldn't load verification. Please contact support if this persists.",
+ protect_check_invalid_sdk_url: "Verification couldn't start. Please contact support.",
+ protect_check_script_load_failed:
+ "Couldn't load verification. This may be caused by a network issue or a Content Security Policy that blocks the verification script. Please try again or contact support.",
session_exists: undefined,
web3_missing_identifier: 'A Web3 Wallet extension cannot be found. Please install one to continue.',
web3_signature_request_rejected: 'You have rejected the signature request. Please try again to continue.',
diff --git a/packages/shared/src/internal/clerk-js/__tests__/completeSignUpFlow.test.ts b/packages/shared/src/internal/clerk-js/__tests__/completeSignUpFlow.test.ts
index 587b2547215..d13513a7c04 100644
--- a/packages/shared/src/internal/clerk-js/__tests__/completeSignUpFlow.test.ts
+++ b/packages/shared/src/internal/clerk-js/__tests__/completeSignUpFlow.test.ts
@@ -69,6 +69,86 @@ describe('completeSignUpFlow', () => {
expect(mockNavigate).toHaveBeenCalledWith('verify-phone', { searchParams: new URLSearchParams() });
});
+ it('navigates to protect check page if protect_check is a missing field', async () => {
+ const mockSignUp = {
+ status: 'missing_requirements',
+ missingFields: ['protect_check'] as SignUpField[],
+ unverifiedFields: ['email_address'],
+ } as SignUpResource;
+
+ await completeSignUpFlow({
+ signUp: mockSignUp,
+ protectCheckPath: 'protect-check',
+ verifyEmailPath: 'verify-email',
+ handleComplete: mockHandleComplete,
+ navigate: mockNavigate,
+ });
+
+ expect(mockHandleComplete).not.toHaveBeenCalled();
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith('protect-check', { searchParams: new URLSearchParams() });
+ });
+
+ it('navigates to protect check page when protectCheck field is present even without missing_fields entry', async () => {
+ const mockSignUp = {
+ status: 'missing_requirements',
+ missingFields: [] as SignUpField[],
+ unverifiedFields: ['email_address'],
+ protectCheck: {
+ status: 'pending',
+ token: 't',
+ sdkUrl: 'https://example.com/sdk.js',
+ },
+ } as unknown as SignUpResource;
+
+ await completeSignUpFlow({
+ signUp: mockSignUp,
+ protectCheckPath: 'protect-check',
+ verifyEmailPath: 'verify-email',
+ handleComplete: mockHandleComplete,
+ navigate: mockNavigate,
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith('protect-check', { searchParams: new URLSearchParams() });
+ });
+
+ it('skips protect check if no protectCheckPath is provided', async () => {
+ const mockSignUp = {
+ status: 'missing_requirements',
+ missingFields: ['protect_check'] as SignUpField[],
+ unverifiedFields: ['email_address'],
+ } as SignUpResource;
+
+ await completeSignUpFlow({
+ signUp: mockSignUp,
+ verifyEmailPath: 'verify-email',
+ handleComplete: mockHandleComplete,
+ navigate: mockNavigate,
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith('verify-email', { searchParams: new URLSearchParams() });
+ });
+
+ it('prioritizes enterprise_sso over protect_check', async () => {
+ const mockSignUp = {
+ status: 'missing_requirements',
+ missingFields: ['enterprise_sso', 'protect_check'] as SignUpField[],
+ authenticateWithRedirect: mockAuthenticateWithRedirect,
+ } as unknown as SignUpResource;
+
+ await completeSignUpFlow({
+ signUp: mockSignUp,
+ protectCheckPath: 'protect-check',
+ handleComplete: mockHandleComplete,
+ navigate: mockNavigate,
+ redirectUrl: 'https://example.com/acs',
+ redirectUrlComplete: 'https://example.com/done',
+ });
+
+ expect(mockAuthenticateWithRedirect).toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
it('does nothing in any other case', async () => {
const mockSignUp = {
status: 'missing_requirements',
diff --git a/packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts b/packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts
new file mode 100644
index 00000000000..f8291b6b3b2
--- /dev/null
+++ b/packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts
@@ -0,0 +1,192 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { ProtectCheckResource } from '@/types';
+
+import { executeProtectCheck } from '../protectCheck';
+
+const fakeContainer = (): HTMLDivElement => ({}) as HTMLDivElement;
+
+const protectCheck = (overrides: Partial = {}): ProtectCheckResource => ({
+ status: 'pending',
+ token: 'challenge-token',
+ sdkUrl: 'https://protect.example.com/sdk.js',
+ ...overrides,
+});
+
+describe('executeProtectCheck', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ describe('URL validation (security)', () => {
+ it('rejects non-HTTPS schemes', async () => {
+ await expect(
+ executeProtectCheck(protectCheck({ sdkUrl: 'http://example.com/sdk.js' }), fakeContainer()),
+ ).rejects.toMatchObject({ code: 'protect_check_invalid_sdk_url' });
+ });
+
+ it('rejects data: URLs (would allow inline JS injection)', async () => {
+ await expect(
+ executeProtectCheck(protectCheck({ sdkUrl: 'data:text/javascript,export default ()=>{}' }), fakeContainer()),
+ ).rejects.toMatchObject({ code: 'protect_check_invalid_sdk_url' });
+ });
+
+ it('rejects javascript: URLs', async () => {
+ await expect(
+ executeProtectCheck(protectCheck({ sdkUrl: 'javascript:void(0)' }), fakeContainer()),
+ ).rejects.toMatchObject({ code: 'protect_check_invalid_sdk_url' });
+ });
+
+ it('rejects URLs containing credentials', async () => {
+ await expect(
+ executeProtectCheck(protectCheck({ sdkUrl: 'https://user:pass@example.com/sdk.js' }), fakeContainer()),
+ ).rejects.toMatchObject({ code: 'protect_check_invalid_sdk_url' });
+ });
+
+ it('rejects unparseable URLs', async () => {
+ await expect(executeProtectCheck(protectCheck({ sdkUrl: 'not a url' }), fakeContainer())).rejects.toMatchObject({
+ code: 'protect_check_invalid_sdk_url',
+ });
+ });
+ });
+
+ describe('script invocation', () => {
+ it('returns the proof token from the script default export', async () => {
+ vi.doMock('https://protect.example.com/sdk-success.js', () => ({
+ default: () => Promise.resolve('proof-token-123'),
+ }));
+
+ const result = await executeProtectCheck(
+ protectCheck({ sdkUrl: 'https://protect.example.com/sdk-success.js' }),
+ fakeContainer(),
+ );
+ expect(result).toBe('proof-token-123');
+ });
+
+ it('passes only the spec-defined fields (token, uiHints, signal) — NOT the full resource', async () => {
+ const fn = vi.fn().mockResolvedValue('proof');
+ vi.doMock('https://protect.example.com/sdk-args.js', () => ({ default: fn }));
+
+ const container = fakeContainer();
+ const controller = new AbortController();
+ await executeProtectCheck(
+ protectCheck({
+ sdkUrl: 'https://protect.example.com/sdk-args.js',
+ token: 'opaque-challenge-token',
+ uiHints: { reason: 'device_new' },
+ }),
+ container,
+ { signal: controller.signal },
+ );
+
+ expect(fn).toHaveBeenCalledWith(container, {
+ token: 'opaque-challenge-token',
+ uiHints: { reason: 'device_new' },
+ signal: controller.signal,
+ });
+ });
+ });
+
+ describe('cancellation', () => {
+ it('rejects with protect_check_aborted if signal is already aborted before load', async () => {
+ const controller = new AbortController();
+ controller.abort();
+
+ await expect(
+ executeProtectCheck(protectCheck({ sdkUrl: 'https://protect.example.com/never-loaded.js' }), fakeContainer(), {
+ signal: controller.signal,
+ }),
+ ).rejects.toMatchObject({ code: 'protect_check_aborted' });
+ });
+
+ it('rejects with protect_check_aborted when signal is aborted during script execution', async () => {
+ const controller = new AbortController();
+ vi.doMock('https://protect.example.com/sdk-aborts.js', () => ({
+ default: (_container: HTMLDivElement, opts: { signal?: AbortSignal }) =>
+ new Promise((_resolve, reject) => {
+ opts.signal?.addEventListener('abort', () => {
+ const err = new Error('aborted by signal');
+ err.name = 'AbortError';
+ reject(err);
+ });
+ }),
+ }));
+
+ const promise = executeProtectCheck(
+ protectCheck({ sdkUrl: 'https://protect.example.com/sdk-aborts.js' }),
+ fakeContainer(),
+ { signal: controller.signal },
+ );
+ controller.abort();
+ await expect(promise).rejects.toMatchObject({ code: 'protect_check_aborted' });
+ });
+
+ it('rejects with protect_check_aborted when script resolves AFTER abort fires (uncooperative SDK)', async () => {
+ const controller = new AbortController();
+ vi.doMock('https://protect.example.com/sdk-uncooperative.js', () => ({
+ default: () =>
+ new Promise(resolve => {
+ // Resolves after a microtask, ignoring the signal entirely
+ setTimeout(() => resolve('late-proof'), 10);
+ }),
+ }));
+
+ const promise = executeProtectCheck(
+ protectCheck({ sdkUrl: 'https://protect.example.com/sdk-uncooperative.js' }),
+ fakeContainer(),
+ { signal: controller.signal },
+ );
+ // Abort while the script is still running
+ setTimeout(() => controller.abort(), 5);
+ await expect(promise).rejects.toMatchObject({ code: 'protect_check_aborted' });
+ });
+ });
+
+ describe('error wrapping', () => {
+ it('wraps load failures with a CSP-aware message and code (no URL leakage)', async () => {
+ // No vi.doMock for this URL → import() fails to resolve
+ await expect(
+ executeProtectCheck(protectCheck({ sdkUrl: 'https://nonexistent.example/missing.js' }), fakeContainer()),
+ ).rejects.toMatchObject({
+ code: 'protect_check_script_load_failed',
+ message: expect.stringContaining('Content Security Policy'),
+ });
+ });
+
+ it('does not leak the sdkUrl in the user-facing load-failure message', async () => {
+ try {
+ await executeProtectCheck(
+ protectCheck({ sdkUrl: 'https://attacker-controlled.example/evil.js' }),
+ fakeContainer(),
+ );
+ throw new Error('should have rejected');
+ } catch (err: any) {
+ expect(err.message).not.toContain('attacker-controlled.example');
+ expect(err.message).not.toContain('evil.js');
+ }
+ });
+
+ it('rejects with protect_check_invalid_script when default export is not a function', async () => {
+ vi.doMock('https://protect.example.com/sdk-no-default.js', () => ({
+ default: { not: 'a function' },
+ }));
+
+ await expect(
+ executeProtectCheck(protectCheck({ sdkUrl: 'https://protect.example.com/sdk-no-default.js' }), fakeContainer()),
+ ).rejects.toMatchObject({ code: 'protect_check_invalid_script' });
+ });
+
+ it('rejects with protect_check_execution_failed when the script throws', async () => {
+ vi.doMock('https://protect.example.com/sdk-throws.js', () => ({
+ default: () => Promise.reject(new Error('script went boom')),
+ }));
+
+ await expect(
+ executeProtectCheck(protectCheck({ sdkUrl: 'https://protect.example.com/sdk-throws.js' }), fakeContainer()),
+ ).rejects.toMatchObject({
+ code: 'protect_check_execution_failed',
+ message: expect.stringContaining('script went boom'),
+ });
+ });
+ });
+});
diff --git a/packages/shared/src/internal/clerk-js/completeSignUpFlow.ts b/packages/shared/src/internal/clerk-js/completeSignUpFlow.ts
index 09b39203e0a..4afdaf13d36 100644
--- a/packages/shared/src/internal/clerk-js/completeSignUpFlow.ts
+++ b/packages/shared/src/internal/clerk-js/completeSignUpFlow.ts
@@ -5,6 +5,7 @@ type CompleteSignUpFlowProps = {
signUp: SignUpResource;
verifyEmailPath?: string;
verifyPhonePath?: string;
+ protectCheckPath?: string;
continuePath?: string;
navigate: (to: string, options?: { searchParams?: URLSearchParams }) => Promise;
handleComplete?: () => Promise;
@@ -17,6 +18,7 @@ export const completeSignUpFlow = ({
signUp,
verifyEmailPath,
verifyPhonePath,
+ protectCheckPath,
continuePath,
navigate,
handleComplete,
@@ -39,6 +41,13 @@ export const completeSignUpFlow = ({
const params = forwardClerkQueryParams();
+ // Per Protect spec §5.1: the protect_check field is the authoritative gating signal.
+ // Sign-up also surfaces it via missing_fields entry; treat either as equivalent.
+ const isProtectGated = !!signUp.protectCheck || signUp.missingFields.some(mf => mf === 'protect_check');
+ if (isProtectGated && protectCheckPath) {
+ return navigate(protectCheckPath, { searchParams: params });
+ }
+
if (signUp.unverifiedFields?.includes('email_address') && verifyEmailPath) {
return navigate(verifyEmailPath, { searchParams: params });
}
diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts
index f81693798e1..aa511d9cabb 100644
--- a/packages/shared/src/internal/clerk-js/constants.ts
+++ b/packages/shared/src/internal/clerk-js/constants.ts
@@ -45,6 +45,7 @@ export const ERROR_CODES = {
CAPTCHA_INVALID: 'captcha_invalid',
FRAUD_DEVICE_BLOCKED: 'device_blocked',
FRAUD_ACTION_BLOCKED: 'action_blocked',
+ PROTECT_CHECK_ALREADY_RESOLVED: 'protect_check_already_resolved',
SIGNUP_RATE_LIMIT_EXCEEDED: 'signup_rate_limit_exceeded',
USER_BANNED: 'user_banned',
USER_DEACTIVATED: 'user_deactivated',
diff --git a/packages/shared/src/internal/clerk-js/protectCheck.ts b/packages/shared/src/internal/clerk-js/protectCheck.ts
new file mode 100644
index 00000000000..060b55993e6
--- /dev/null
+++ b/packages/shared/src/internal/clerk-js/protectCheck.ts
@@ -0,0 +1,153 @@
+import { ClerkRuntimeError } from '../../error';
+import type { ProtectCheckResource } from '../../types';
+
+export interface ExecuteProtectCheckOptions {
+ /**
+ * Signals that the caller no longer needs the proof token (component unmounted, user
+ * navigated away, etc.). When the signal aborts:
+ * - If the script has not yet been imported, `executeProtectCheck` rejects with
+ * `protect_check_aborted` without loading the script.
+ * - The signal is forwarded to the script as `{ signal }` in the second argument so
+ * cooperating SDKs can cancel any in-flight UI / network work.
+ * - Even if the script ignores the signal and resolves with a token, the helper
+ * re-checks `signal.aborted` after the await and rejects with `protect_check_aborted`
+ * so the caller never observes a "successful" abort.
+ *
+ * Scripts that don't honor the signal will continue to run; this is best-effort by design.
+ */
+ signal?: AbortSignal;
+}
+
+interface ScriptInitOptions {
+ token: string;
+ uiHints?: Record;
+ signal?: AbortSignal;
+}
+
+type ScriptDefault = (container: HTMLDivElement, init: ScriptInitOptions) => Promise;
+
+/**
+ * Validates the `sdk_url` returned by the server before passing it to dynamic `import()`.
+ *
+ * Rejects:
+ * - Anything that fails URL parsing (relative paths, garbage strings)
+ * - Non-`https:` schemes — including `http:`, `data:`, `blob:`, `javascript:`. The server
+ * contract (FAPI spec §2.1) says the URL is always HTTPS, but the dynamic-import
+ * primitive accepts `data:`/`blob:` modules which would let a tampered response inject
+ * arbitrary code into the host page.
+ * - URLs containing credentials (`user:pass@host`) — phishing surface, no legitimate use.
+ *
+ * Throws `ClerkRuntimeError` with code `protect_check_invalid_sdk_url`. We deliberately do
+ * NOT silently strip an invalid `protect_check` from the resource: the gate must remain
+ * present so the user can't bypass it by manipulating the response. Fail-closed.
+ */
+function assertValidSdkUrl(sdkUrl: string): URL {
+ let parsed: URL;
+ try {
+ parsed = new URL(sdkUrl);
+ } catch {
+ throw new ClerkRuntimeError('Protect check sdk_url is not a valid URL', {
+ code: 'protect_check_invalid_sdk_url',
+ });
+ }
+ if (parsed.protocol !== 'https:') {
+ throw new ClerkRuntimeError('Protect check sdk_url must use HTTPS', {
+ code: 'protect_check_invalid_sdk_url',
+ });
+ }
+ if (parsed.username || parsed.password) {
+ throw new ClerkRuntimeError('Protect check sdk_url must not contain credentials', {
+ code: 'protect_check_invalid_sdk_url',
+ });
+ }
+ return parsed;
+}
+
+/**
+ * Loads the Protect challenge SDK from `protectCheck.sdkUrl`, hands it the container element
+ * and the spec-defined init payload (`token`, `uiHints`, `signal`), and returns the proof
+ * token the SDK produces.
+ *
+ * The SDK script must:
+ * - Be a valid ES module served over HTTPS
+ * - Have a default export of the shape `(container, { token, uiHints, signal }) => Promise`
+ * - Honor the `signal` to abort any pending work (best-effort)
+ *
+ * Per FAPI spec §5.2, only the spec-defined fields (`token`, optional `ui_hints`) are
+ * surfaced to the script — the full sign-up/sign-in resource is intentionally NOT passed
+ * to minimize the trust surface granted to third-party Protect scripts.
+ *
+ * Failure modes are surfaced as `ClerkRuntimeError` with one of:
+ * - `protect_check_invalid_sdk_url` — URL fails the safety checks above
+ * - `protect_check_aborted` — caller aborted before or during execution
+ * - `protect_check_script_load_failed` — network error, CSP block, or invalid module
+ * - `protect_check_invalid_script` — module loaded but no callable default export
+ * - `protect_check_execution_failed` — the script's default export threw
+ */
+export async function executeProtectCheck(
+ protectCheck: Pick,
+ container: HTMLDivElement,
+ options: ExecuteProtectCheckOptions = {},
+): Promise {
+ const { signal } = options;
+ const { sdkUrl, token, uiHints } = protectCheck;
+
+ const validated = assertValidSdkUrl(sdkUrl);
+
+ if (signal?.aborted) {
+ throw new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' });
+ }
+
+ let mod: Record;
+ try {
+ mod = await import(/* webpackIgnore: true */ validated.toString());
+ } catch (err) {
+ // The browser surfaces CSP-blocked imports as the same error shape as a network error
+ // (typically a TypeError "Failed to fetch dynamically imported module"), so we can't
+ // reliably distinguish them. Surface a generic message to the UI — the URL is NOT
+ // included to avoid a phishing surface where a tampered response could place an
+ // attacker-chosen URL in the auth UI. Diagnostic detail goes to the original error.
+ const original = err instanceof Error ? err.message : String(err);
+ throw new ClerkRuntimeError(
+ 'Protect check script failed to load. This is commonly caused by a Content Security ' +
+ 'Policy that blocks the script origin (add it to your script-src directive), a ' +
+ `network error, or an invalid module. (Original error: ${original})`,
+ { code: 'protect_check_script_load_failed' },
+ );
+ }
+
+ if (signal?.aborted) {
+ throw new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' });
+ }
+
+ if (typeof mod.default !== 'function') {
+ throw new ClerkRuntimeError('Protect check script does not export a default function', {
+ code: 'protect_check_invalid_script',
+ });
+ }
+
+ let proofToken: string;
+ try {
+ proofToken = await (mod.default as ScriptDefault)(container, { token, uiHints, signal });
+ } catch (err) {
+ // Distinguish abort-induced rejections from genuine script errors: only relabel as
+ // `protect_check_aborted` when the error looks like an abort (`AbortError`), otherwise
+ // surface the script's actual failure so production diagnostics aren't masked.
+ const looksLikeAbort = err instanceof Error && err.name === 'AbortError';
+ if (signal?.aborted && looksLikeAbort) {
+ throw new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' });
+ }
+ const original = err instanceof Error ? err.message : String(err);
+ throw new ClerkRuntimeError(`Protect check script execution failed: ${original}`, {
+ code: 'protect_check_execution_failed',
+ });
+ }
+
+ // The script may have ignored the signal and resolved with a token after the abort fired.
+ // Re-check here so callers get a consistent contract: if you aborted, you never see a token.
+ if (signal?.aborted) {
+ throw new ClerkRuntimeError('Protect check aborted by caller', { code: 'protect_check_aborted' });
+ }
+
+ return proofToken;
+}
diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts
index 7c91ed39498..26381d9e172 100644
--- a/packages/shared/src/types/json.ts
+++ b/packages/shared/src/types/json.ts
@@ -143,6 +143,18 @@ export interface SignUpJSON extends ClerkResourceJSON {
legal_accepted_at: number | null;
locale: string | null;
verifications: SignUpVerificationsJSON | null;
+ protect_check: ProtectCheckJSON | null;
+}
+
+export interface ProtectCheckJSON {
+ /**
+ * Always `'pending'` when surfaced to clients. Completed checks are never emitted on the wire.
+ */
+ status: 'pending';
+ token: string;
+ sdk_url: string;
+ expires_at?: number;
+ ui_hints?: Record;
}
/**
diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts
index 222509565bb..c18f3ca3b40 100644
--- a/packages/shared/src/types/localization.ts
+++ b/packages/shared/src/types/localization.ts
@@ -399,6 +399,11 @@ export type __internal_LocalizationResource = {
subtitle: LocalizationValue;
noAvailableWallets: LocalizationValue;
};
+ protectCheck: {
+ title: LocalizationValue;
+ subtitle: LocalizationValue;
+ loading: LocalizationValue;
+ };
};
signIn: {
start: {
@@ -581,6 +586,11 @@ export type __internal_LocalizationResource = {
title: LocalizationValue;
subtitle: LocalizationValue;
};
+ protectCheck: {
+ title: LocalizationValue;
+ subtitle: LocalizationValue;
+ loading: LocalizationValue;
+ };
};
reverification: {
password: {
@@ -1448,11 +1458,18 @@ type WithParamName = T &
Partial>}`, LocalizationValue>>;
type UnstableErrors = WithParamName<{
+ action_blocked: LocalizationValue;
avatar_file_type_invalid: LocalizationValue;
avatar_file_size_exceeded: LocalizationValue;
external_account_not_found: LocalizationValue;
identification_deletion_failed: LocalizationValue;
phone_number_exists: LocalizationValue;
+ protect_check_aborted: LocalizationValue;
+ protect_check_already_resolved: LocalizationValue;
+ protect_check_execution_failed: LocalizationValue;
+ protect_check_invalid_script: LocalizationValue;
+ protect_check_invalid_sdk_url: LocalizationValue;
+ protect_check_script_load_failed: LocalizationValue;
form_identifier_not_found: LocalizationValue;
captcha_unavailable: LocalizationValue;
captcha_invalid: LocalizationValue;
diff --git a/packages/shared/src/types/signIn.ts b/packages/shared/src/types/signIn.ts
index 031cf9e76eb..b33e0f4563f 100644
--- a/packages/shared/src/types/signIn.ts
+++ b/packages/shared/src/types/signIn.ts
@@ -1,6 +1,7 @@
import type {
ClerkResourceJSON,
ClientTrustState,
+ ProtectCheckJSON,
SignInFirstFactorJSON,
SignInSecondFactorJSON,
UserDataJSON,
@@ -26,6 +27,7 @@ import type {
UserData,
} from './signInCommon';
import type { SignInFutureResource } from './signInFuture';
+import type { ProtectCheckResource } from './signUpCommon';
import type { SignInJSONSnapshot } from './snapshots';
import type { CreateEmailLinkFlowReturn, VerificationResource } from './verification';
import type { AuthenticateWithWeb3Params } from './web3Wallet';
@@ -50,6 +52,12 @@ export interface SignInResource extends ClerkResource {
identifier: string | null;
createdSessionId: string | null;
userData: UserData;
+ /**
+ * The current protect check challenge, if one is pending. Mid-flow fraud-prevention gate
+ * issued by Clerk Protect. When non-null, the client must load the SDK at `sdkUrl`, run the
+ * challenge with `token`, and submit the resulting proof token via `submitProtectCheck`.
+ */
+ protectCheck: ProtectCheckResource | null;
create: (params: SignInCreateParams) => Promise;
@@ -63,6 +71,13 @@ export interface SignInResource extends ClerkResource {
attemptSecondFactor: (params: AttemptSecondFactorParams) => Promise;
+ /**
+ * Submits a proof token to resolve a pending protect check challenge. The response may contain
+ * another `protectCheck` (a chained challenge) which must be resolved iteratively. After the
+ * gate clears, the client should retry the operation that was gated.
+ */
+ submitProtectCheck: (params: { proofToken: string }) => Promise;
+
authenticateWithRedirect: (params: AuthenticateWithRedirectParams) => Promise;
authenticateWithPopup: (params: AuthenticateWithPopupParams) => Promise;
@@ -111,4 +126,5 @@ export interface SignInJSON extends ClerkResourceJSON {
first_factor_verification: VerificationJSON | null;
second_factor_verification: VerificationJSON | null;
created_session_id: string | null;
+ protect_check?: ProtectCheckJSON | null;
}
diff --git a/packages/shared/src/types/signInCommon.ts b/packages/shared/src/types/signInCommon.ts
index 40e255b8cf1..8e1fb480c29 100644
--- a/packages/shared/src/types/signInCommon.ts
+++ b/packages/shared/src/types/signInCommon.ts
@@ -63,6 +63,7 @@ export type SignInStatus =
| 'needs_second_factor'
| 'needs_client_trust'
| 'needs_new_password'
+ | 'needs_protect_check'
| 'complete';
export type SignInIdentifier =
diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts
index b320d3afcf7..c524228137b 100644
--- a/packages/shared/src/types/signInFuture.ts
+++ b/packages/shared/src/types/signInFuture.ts
@@ -2,6 +2,7 @@ import type { ClerkError } from '../errors/clerkError';
import type { SetActiveNavigate } from './clerk';
import type { PhoneCodeChannel } from './phoneCodeChannel';
import type { SignInFirstFactor, SignInSecondFactor, SignInStatus, UserData } from './signInCommon';
+import type { ProtectCheckResource } from './signUpCommon';
import type { OAuthStrategy, PasskeyStrategy, TicketStrategy, Web3Strategy } from './strategies';
import type { VerificationResource } from './verification';
import type { Web3Provider } from './web3';
@@ -368,6 +369,11 @@ export interface SignInFutureResource {
*/
readonly userData: UserData;
+ /**
+ * The current protect check challenge, if one is pending.
+ */
+ readonly protectCheck: ProtectCheckResource | null;
+
/**
* Indicates that the sign-in can be discarded (has been finalized or explicitly reset).
*
@@ -559,6 +565,12 @@ export interface SignInFutureResource {
*/
passkey: (params?: SignInFuturePasskeyParams) => Promise<{ error: ClerkError | null }>;
+ /**
+ * Submits a proof token to resolve a pending protect check challenge. The response may contain
+ * another `protectCheck` (a chained challenge) which must be resolved iteratively.
+ */
+ submitProtectCheck: (params: { proofToken: string }) => Promise<{ error: ClerkError | null }>;
+
/**
* Used to convert a sign-in with `status === 'complete'` into an active session. Will cause anything observing the
* session state (such as the `useUser()` hook) to update automatically.
diff --git a/packages/shared/src/types/signUp.ts b/packages/shared/src/types/signUp.ts
index 38da8659e9b..453278a60e1 100644
--- a/packages/shared/src/types/signUp.ts
+++ b/packages/shared/src/types/signUp.ts
@@ -6,6 +6,7 @@ import type { ClerkResource } from './resource';
import type {
AttemptVerificationParams,
PrepareVerificationParams,
+ ProtectCheckResource,
SignUpAuthenticateWithSolanaParams,
SignUpAuthenticateWithWeb3Params,
SignUpCreateParams,
@@ -48,6 +49,7 @@ export interface SignUpResource extends ClerkResource {
missingFields: SignUpField[];
unverifiedFields: SignUpIdentificationField[];
verifications: SignUpVerificationsResource;
+ protectCheck: ProtectCheckResource | null;
username: string | null;
firstName: string | null;
@@ -104,6 +106,8 @@ export interface SignUpResource extends ClerkResource {
},
) => Promise;
+ submitProtectCheck: (params: { proofToken: string }) => Promise;
+
authenticateWithMetamask: (params?: SignUpAuthenticateWithWeb3Params) => Promise;
authenticateWithCoinbaseWallet: (params?: SignUpAuthenticateWithWeb3Params) => Promise;
authenticateWithOKXWallet: (params?: SignUpAuthenticateWithWeb3Params) => Promise;
diff --git a/packages/shared/src/types/signUpCommon.ts b/packages/shared/src/types/signUpCommon.ts
index 41c15035b46..40859a3f13d 100644
--- a/packages/shared/src/types/signUpCommon.ts
+++ b/packages/shared/src/types/signUpCommon.ts
@@ -24,7 +24,20 @@ import type { VerificationResource } from './verification';
export type SignUpStatus = 'missing_requirements' | 'complete' | 'abandoned';
-export type SignUpField = SignUpAttributeField | SignUpIdentificationField;
+export type ProtectCheckField = 'protect_check';
+
+export type SignUpField = SignUpAttributeField | SignUpIdentificationField | ProtectCheckField;
+
+export interface ProtectCheckResource {
+ /**
+ * Always `'pending'` when surfaced to clients.
+ */
+ status: 'pending';
+ token: string;
+ sdkUrl: string;
+ expiresAt?: number;
+ uiHints?: Record;
+}
export type PrepareVerificationParams =
| {
diff --git a/packages/shared/src/types/signUpFuture.ts b/packages/shared/src/types/signUpFuture.ts
index 1daf1239ece..2435758d7a3 100644
--- a/packages/shared/src/types/signUpFuture.ts
+++ b/packages/shared/src/types/signUpFuture.ts
@@ -1,7 +1,13 @@
import type { ClerkError } from '../errors/clerkError';
import type { SetActiveNavigate } from './clerk';
import type { PhoneCodeChannel } from './phoneCodeChannel';
-import type { SignUpField, SignUpIdentificationField, SignUpStatus, SignUpVerificationResource } from './signUpCommon';
+import type {
+ ProtectCheckResource,
+ SignUpField,
+ SignUpIdentificationField,
+ SignUpStatus,
+ SignUpVerificationResource,
+} from './signUpCommon';
import type {
AppleIdTokenStrategy,
EnterpriseSSOStrategy,
@@ -500,6 +506,11 @@ export interface SignUpFutureResource {
*/
readonly locale: string | null;
+ /**
+ * The current protect check challenge, if one is pending.
+ */
+ readonly protectCheck: ProtectCheckResource | null;
+
/**
* Indicates that the sign-up can be discarded (has been finalized or explicitly reset).
*
@@ -555,6 +566,12 @@ export interface SignUpFutureResource {
*/
web3: (params: SignUpFutureWeb3Params) => Promise<{ error: ClerkError | null }>;
+ /**
+ * Submits a proof token to resolve a pending protect check challenge. The response may contain
+ * another `protectCheck` (a chained challenge) which must be resolved iteratively.
+ */
+ submitProtectCheck: (params: { proofToken: string }) => Promise<{ error: ClerkError | null }>;
+
/**
* Used to convert a sign-up with `status === 'complete'` into an active session. Will cause anything observing the
* session state (such as the `useUser()` hook) to update automatically.
diff --git a/packages/ui/src/common/EmailLinkVerify.tsx b/packages/ui/src/common/EmailLinkVerify.tsx
index 1b00cfd3232..40e9e72fb5a 100644
--- a/packages/ui/src/common/EmailLinkVerify.tsx
+++ b/packages/ui/src/common/EmailLinkVerify.tsx
@@ -37,6 +37,7 @@ export const EmailLinkVerify = (props: EmailLinkVerifyProps) => {
signUp,
verifyEmailPath,
verifyPhonePath,
+ protectCheckPath: '../protect-check',
continuePath,
navigate,
});
diff --git a/packages/ui/src/components/SignIn/ResetPassword.tsx b/packages/ui/src/components/SignIn/ResetPassword.tsx
index 7d8f7198705..77ed1436398 100644
--- a/packages/ui/src/components/SignIn/ResetPassword.tsx
+++ b/packages/ui/src/components/SignIn/ResetPassword.tsx
@@ -14,6 +14,7 @@ import { Col, descriptors, localizationKeys, useLocalizations } from '../../cust
import { useConfirmPassword } from '../../hooks';
import { useSupportEmail } from '../../hooks/useSupportEmail';
import { useRouter } from '../../router';
+import { isSignInProtectGated } from './handleProtectCheck';
const ResetPasswordInternal = () => {
const signIn = useCoreSignIn();
@@ -78,10 +79,15 @@ const ResetPasswordInternal = () => {
passwordField.clearFeedback();
confirmField.clearFeedback();
try {
- const { status, createdSessionId } = await signIn.resetPassword({
+ const res = await signIn.resetPassword({
password: passwordField.value,
signOutOfOtherSessions: sessionsField.checked,
});
+ const { status, createdSessionId } = res;
+
+ if (isSignInProtectGated(res)) {
+ return navigate('../protect-check');
+ }
switch (status) {
case 'complete':
@@ -93,6 +99,8 @@ const ResetPasswordInternal = () => {
return console.error(clerkInvalidFAPIResponse(status, supportEmail));
case 'needs_second_factor':
return navigate('../factor-two');
+ case 'needs_protect_check':
+ return navigate('../protect-check');
default:
return console.error(clerkInvalidFAPIResponse(status, supportEmail));
}
diff --git a/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx
index c7a112ca08b..b62a459a5e7 100644
--- a/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx
+++ b/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx
@@ -12,6 +12,7 @@ import { useCoreSignIn, useSignInContext } from '../../contexts';
import { useSupportEmail } from '../../hooks/useSupportEmail';
import { type LocalizationKey, localizationKeys } from '../../localization';
import { useRouter } from '../../router';
+import { isSignInProtectGated } from './handleProtectCheck';
export type SignInFactorOneAlternativeChannelCodeCard = Pick<
VerificationCodeCardProps,
@@ -63,6 +64,10 @@ export const SignInFactorOneAlternativeChannelCodeForm = (props: SignInFactorOne
.then(async res => {
await resolve();
+ if (isSignInProtectGated(res)) {
+ return navigate('../protect-check');
+ }
+
switch (res.status) {
case 'complete':
return setActive({
@@ -75,6 +80,8 @@ export const SignInFactorOneAlternativeChannelCodeForm = (props: SignInFactorOne
return navigate('../factor-two');
case 'needs_new_password':
return navigate('../reset-password');
+ case 'needs_protect_check':
+ return navigate('../protect-check');
default:
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
}
diff --git a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx
index da2863fd3d9..9191fa920e8 100644
--- a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx
+++ b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx
@@ -14,6 +14,7 @@ import { useFetch } from '../../hooks';
import { useSupportEmail } from '../../hooks/useSupportEmail';
import { type LocalizationKey } from '../../localization';
import { useRouter } from '../../router';
+import { isSignInProtectGated } from './handleProtectCheck';
export type SignInFactorOneCodeCard = Pick<
VerificationCodeCardProps,
@@ -94,6 +95,10 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) =>
.then(async res => {
await resolve();
+ if (isSignInProtectGated(res)) {
+ return navigate('../protect-check');
+ }
+
switch (res.status) {
case 'complete':
return setActive({
@@ -106,6 +111,8 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) =>
return navigate('../factor-two');
case 'needs_new_password':
return navigate('../reset-password');
+ case 'needs_protect_check':
+ return navigate('../protect-check');
default:
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
}
diff --git a/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx
index f4a453b4fe5..e91fcb0c708 100644
--- a/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx
+++ b/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx
@@ -15,6 +15,7 @@ import { useCoreSignIn, useSignInContext } from '../../contexts';
import { descriptors, Flex, Flow, localizationKeys } from '../../customizables';
import { useSupportEmail } from '../../hooks/useSupportEmail';
import { useRouter } from '../../router/RouteContext';
+import { isSignInProtectGated } from './handleProtectCheck';
import { HavingTrouble } from './HavingTrouble';
import { useResetPasswordFactor } from './useResetPasswordFactor';
@@ -74,6 +75,9 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps)
void signIn
.attemptFirstFactor({ strategy: 'password', password: passwordControl.value })
.then(res => {
+ if (isSignInProtectGated(res)) {
+ return navigate('../protect-check');
+ }
switch (res.status) {
case 'complete':
return setActive({
@@ -86,6 +90,8 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps)
return navigate('../factor-two');
case 'needs_client_trust':
return navigate('../client-trust');
+ case 'needs_protect_check':
+ return navigate('../protect-check');
default:
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
}
diff --git a/packages/ui/src/components/SignIn/SignInFactorTwoBackupCodeCard.tsx b/packages/ui/src/components/SignIn/SignInFactorTwoBackupCodeCard.tsx
index 119eb6f3308..0973835851f 100644
--- a/packages/ui/src/components/SignIn/SignInFactorTwoBackupCodeCard.tsx
+++ b/packages/ui/src/components/SignIn/SignInFactorTwoBackupCodeCard.tsx
@@ -15,6 +15,7 @@ import { useCoreSignIn, useSignInContext } from '../../contexts';
import { Col, descriptors, localizationKeys } from '../../customizables';
import { useSupportEmail } from '../../hooks/useSupportEmail';
import { useRouter } from '../../router';
+import { isSignInProtectGated } from './handleProtectCheck';
import { isResetPasswordStrategy } from './utils';
type SignInFactorTwoBackupCodeCardProps = {
@@ -45,6 +46,9 @@ export const SignInFactorTwoBackupCodeCard = (props: SignInFactorTwoBackupCodeCa
return signIn
.attemptSecondFactor({ strategy: 'backup_code', code: codeControl.value })
.then(res => {
+ if (isSignInProtectGated(res)) {
+ return navigate('../protect-check');
+ }
switch (res.status) {
case 'complete':
if (isResettingPassword(res) && res.createdSessionId) {
@@ -58,6 +62,8 @@ export const SignInFactorTwoBackupCodeCard = (props: SignInFactorTwoBackupCodeCa
await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl });
},
});
+ case 'needs_protect_check':
+ return navigate('../protect-check');
default:
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
}
diff --git a/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx
index 0cf1ad32f57..1690f037424 100644
--- a/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx
+++ b/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx
@@ -14,6 +14,7 @@ import { localizationKeys, Text } from '../../customizables';
import { useSupportEmail } from '../../hooks/useSupportEmail';
import type { LocalizationKey } from '../../localization';
import { useRouter } from '../../router';
+import { isSignInProtectGated } from './handleProtectCheck';
import { isResetPasswordStrategy } from './utils';
export type SignInFactorTwoCodeCard = Pick & {
@@ -84,6 +85,9 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) =>
.attemptSecondFactor({ strategy: props.factor.strategy, code })
.then(async res => {
await resolve();
+ if (isSignInProtectGated(res)) {
+ return navigate('../protect-check');
+ }
switch (res.status) {
case 'complete':
if (isResettingPassword(res) && res.createdSessionId) {
@@ -97,6 +101,8 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) =>
await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl });
},
});
+ case 'needs_protect_check':
+ return navigate('../protect-check');
default:
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
}
diff --git a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx
new file mode 100644
index 00000000000..dcb75d270ba
--- /dev/null
+++ b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx
@@ -0,0 +1,223 @@
+import { isClerkAPIResponseError } from '@clerk/shared/error';
+import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants';
+import { executeProtectCheck } from '@clerk/shared/internal/clerk-js/protectCheck';
+import { useClerk } from '@clerk/shared/react';
+import type { SignInResource } from '@clerk/shared/types';
+import React from 'react';
+
+import { Card } from '@/ui/elements/Card';
+import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
+import { Header } from '@/ui/elements/Header';
+import { handleError } from '@/ui/utils/errorHandler';
+
+import { withRedirectToAfterSignIn } from '../../common';
+import { useCoreSignIn, useSignInContext } from '../../contexts';
+import { Box, Col, descriptors, Flow, localizationKeys, useLocalizations } from '../../customizables';
+import { useRouter } from '../../router';
+
+/**
+ * Routes the user to the next step after a protect check has been resolved (or short-circuits
+ * to the same route to handle a chained challenge).
+ *
+ * Per spec §4.3: after the gate clears, the client should retry the operation that was gated.
+ * For most steps (factor-one/factor-two cards), the underlying card uses `useFetch` to call
+ * `prepareFirstFactor`/`prepareSecondFactor` on mount, so navigating back is sufficient to
+ * re-trigger the gated work.
+ */
+function navigateNext(signIn: SignInResource, navigate: (to: string) => Promise) {
+ // Chained challenge — stay here and re-run the new challenge on next render. Both
+ // signals are checked: `protectCheck` is the authoritative field per spec §5.1, and
+ // `'needs_protect_check'` is the SDK-version-gated status from spec §2.2.2.
+ if (signIn.protectCheck || signIn.status === 'needs_protect_check') {
+ return navigate('.');
+ }
+
+ switch (signIn.status) {
+ case 'needs_first_factor':
+ return navigate('../factor-one');
+ case 'needs_second_factor':
+ return navigate('../factor-two');
+ case 'needs_client_trust':
+ return navigate('../client-trust');
+ case 'needs_new_password':
+ return navigate('../reset-password');
+ case 'complete':
+ // Finalization is handled by the caller via setActive; just bounce to index.
+ return navigate('..');
+ default:
+ return navigate('..');
+ }
+}
+
+function SignInProtectCheckInternal(): JSX.Element {
+ const card = useCardState();
+ const { t } = useLocalizations();
+ const signIn = useCoreSignIn();
+ const { navigate } = useRouter();
+ const { setActive } = useClerk();
+ const ctx = useSignInContext();
+ const { afterSignInUrl, navigateOnSetActive } = ctx;
+
+ const containerRef = React.useRef(null);
+ const isRunningRef = React.useRef(false);
+ const [isRunning, setIsRunning] = React.useState(false);
+
+ React.useEffect(() => {
+ const protectCheck = signIn.protectCheck;
+ if (!protectCheck || isRunningRef.current) {
+ return;
+ }
+
+ // Cancellation: if the component unmounts (route change, navigation away) or the
+ // dependency changes (chained challenge with a new protectCheck reference), abort
+ // any in-flight script execution and skip downstream state updates.
+ const abortController = new AbortController();
+ let cancelled = false;
+
+ // Per spec §5.1.4: do not attempt to solve an expired challenge.
+ // Reload the resource so the server can mint a fresh challenge before re-routing,
+ // otherwise the local stale `signIn.protectCheck` would re-trigger this same effect
+ // and loop indefinitely with no user feedback.
+ if (protectCheck.expiresAt !== undefined && protectCheck.expiresAt < Date.now()) {
+ isRunningRef.current = true;
+ setIsRunning(true);
+ void (async () => {
+ try {
+ await signIn.reload();
+ } catch (err: any) {
+ if (!cancelled) {
+ handleError(err, [], card.setError);
+ }
+ } finally {
+ if (!cancelled) {
+ isRunningRef.current = false;
+ setIsRunning(false);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ abortController.abort();
+ isRunningRef.current = false;
+ };
+ }
+
+ const container = containerRef.current;
+ if (!container) {
+ return;
+ }
+
+ isRunningRef.current = true;
+ setIsRunning(true);
+
+ const finalizeIfComplete = async (updatedSignIn: SignInResource) => {
+ if (cancelled) {
+ return false;
+ }
+ if (updatedSignIn.status === 'complete' && updatedSignIn.createdSessionId) {
+ await setActive({
+ session: updatedSignIn.createdSessionId,
+ navigate: async ({ session, decorateUrl }) => {
+ await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl });
+ },
+ });
+ return true;
+ }
+ return false;
+ };
+
+ const runChallenge = async () => {
+ try {
+ const proofToken = await executeProtectCheck(protectCheck, container, {
+ signal: abortController.signal,
+ });
+ if (cancelled) {
+ return;
+ }
+
+ let updatedSignIn: SignInResource;
+ try {
+ updatedSignIn = await signIn.submitProtectCheck({ proofToken });
+ } catch (err) {
+ if (cancelled) {
+ return;
+ }
+ // Per spec §3.3, §5.3.4: protect_check_already_resolved is retry-safe.
+ // Reload to clear the stale local protectCheck; otherwise navigateNext would
+ // see protectCheck still set and self-loop on this route.
+ if (isClerkAPIResponseError(err) && err.errors?.[0]?.code === ERROR_CODES.PROTECT_CHECK_ALREADY_RESOLVED) {
+ await signIn.reload();
+ if (cancelled) {
+ return;
+ }
+ await navigateNext(signIn, navigate);
+ return;
+ }
+ throw err;
+ }
+ if (cancelled) {
+ return;
+ }
+
+ if (await finalizeIfComplete(updatedSignIn)) {
+ return;
+ }
+
+ if (cancelled) {
+ return;
+ }
+ await navigateNext(updatedSignIn, navigate);
+ } catch (err: any) {
+ if (cancelled) {
+ return;
+ }
+ handleError(err, [], card.setError);
+ } finally {
+ if (!cancelled) {
+ isRunningRef.current = false;
+ setIsRunning(false);
+ }
+ }
+ };
+
+ void runChallenge();
+
+ return () => {
+ cancelled = true;
+ abortController.abort();
+ // Reset the guard so the next mount / dep-change can re-run; this is what makes
+ // chained challenges work correctly across re-renders.
+ isRunningRef.current = false;
+ };
+ }, [signIn.protectCheck]);
+
+ return (
+
+
+
+
+
+
+
+ {card.error}
+
+
+ {isRunning && !card.error ? (
+ {t(localizationKeys('signIn.protectCheck.loading'))}
+ ) : null}
+
+
+
+
+
+ );
+}
+
+export const SignInProtectCheck = withRedirectToAfterSignIn(withCardStateProvider(SignInProtectCheckInternal));
diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx
index 73cb247af7e..953e8df98b3 100644
--- a/packages/ui/src/components/SignIn/SignInStart.tsx
+++ b/packages/ui/src/components/SignIn/SignInStart.tsx
@@ -40,6 +40,7 @@ import { useSupportEmail } from '../../hooks/useSupportEmail';
import { useTotalEnabledAuthMethods } from '../../hooks/useTotalEnabledAuthMethods';
import { useRouter } from '../../router';
import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer';
+import { isSignInProtectGated } from './handleProtectCheck';
import { hasMultipleEnterpriseConnections, useHandleAuthenticateWithPasskey } from './shared';
import { SignInAlternativePhoneCodePhoneNumberCard } from './SignInAlternativePhoneCodePhoneNumberCard';
import { SignInSocialButtons } from './SignInSocialButtons';
@@ -225,6 +226,9 @@ function SignInStartInternal(): JSX.Element {
ticket: organizationTicket,
})
.then(res => {
+ if (isSignInProtectGated(res)) {
+ return navigate('protect-check');
+ }
switch (res.status) {
case 'needs_first_factor': {
if (!hasOnlyEnterpriseSSOFirstFactors(res) || hasMultipleEnterpriseConnections(res.supportedFirstFactors)) {
@@ -237,6 +241,8 @@ function SignInStartInternal(): JSX.Element {
return navigate('factor-two');
case 'needs_client_trust':
return navigate('client-trust');
+ case 'needs_protect_check':
+ return navigate('protect-check');
case 'complete':
removeClerkQueryParam('__clerk_ticket');
return clerk.setActive({
@@ -377,6 +383,10 @@ function SignInStartInternal(): JSX.Element {
try {
const res = await safePasswordSignInForEnterpriseSSOInstance(signIn.create(buildSignInParams(fields)), fields);
+ if (isSignInProtectGated(res)) {
+ return navigate('protect-check');
+ }
+
switch (res.status) {
case 'needs_identifier':
// Check if we need to initiate an enterprise sso flow
@@ -395,6 +405,8 @@ function SignInStartInternal(): JSX.Element {
return navigate('factor-two');
case 'needs_client_trust':
return navigate('client-trust');
+ case 'needs_protect_check':
+ return navigate('protect-check');
case 'complete':
return clerk.setActive({
session: res.createdSessionId,
diff --git a/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
new file mode 100644
index 00000000000..23d6c3d5cb7
--- /dev/null
+++ b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
@@ -0,0 +1,200 @@
+import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error';
+import type { SignInResource } from '@clerk/shared/types';
+import { waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render } from '@/test/utils';
+
+import { SignInProtectCheck } from '../SignInProtectCheck';
+
+vi.mock('@clerk/shared/internal/clerk-js/protectCheck', () => ({
+ executeProtectCheck: vi.fn(),
+}));
+
+import { executeProtectCheck } from '@clerk/shared/internal/clerk-js/protectCheck';
+
+const { createFixtures } = bindCreateFixtures('SignIn');
+
+const mockExecute = executeProtectCheck as unknown as ReturnType;
+
+beforeEach(() => {
+ mockExecute.mockReset();
+});
+
+describe('SignInProtectCheck', () => {
+ it('renders verification UI', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ mockExecute.mockReturnValue(new Promise(() => {})); // never resolves
+
+ const { findByText } = render(, { wrapper });
+
+ expect(await findByText(/verifying your request/i)).toBeInTheDocument();
+ });
+
+ it('runs the SDK challenge and submits the proof token, then routes by status', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck({ sdkUrl: 'https://protect.example.com/v1.js' });
+ });
+ mockExecute.mockResolvedValue('proof-abc');
+ fixtures.signIn.submitProtectCheck.mockResolvedValue({
+ status: 'needs_first_factor',
+ protectCheck: null,
+ createdSessionId: null,
+ } as unknown as SignInResource);
+
+ render(, { wrapper });
+
+ await waitFor(() => {
+ expect(mockExecute).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sdkUrl: 'https://protect.example.com/v1.js',
+ token: 'challenge-token',
+ }),
+ expect.any(HTMLDivElement),
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
+ );
+ expect(fixtures.signIn.submitProtectCheck).toHaveBeenCalledWith({ proofToken: 'proof-abc' });
+ expect(fixtures.router.navigate).toHaveBeenCalledWith('../factor-one');
+ });
+ });
+
+ it('routes to factor-two when status becomes needs_second_factor', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ mockExecute.mockResolvedValue('proof-abc');
+ fixtures.signIn.submitProtectCheck.mockResolvedValue({
+ status: 'needs_second_factor',
+ protectCheck: null,
+ createdSessionId: null,
+ } as unknown as SignInResource);
+
+ render(, { wrapper });
+
+ await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('../factor-two'));
+ });
+
+ it('finalizes the session when status becomes complete', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ mockExecute.mockResolvedValue('proof-abc');
+ fixtures.signIn.submitProtectCheck.mockResolvedValue({
+ status: 'complete',
+ protectCheck: null,
+ createdSessionId: 'sess_123',
+ } as unknown as SignInResource);
+
+ render(, { wrapper });
+
+ await waitFor(() => expect(fixtures.clerk.setActive).toHaveBeenCalled());
+ });
+
+ it('reloads the resource (does not run SDK) when expiresAt is in the past', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck({ expiresAt: Date.now() - 60_000 });
+ });
+ const reloadMock = vi.fn().mockResolvedValue(fixtures.signIn);
+ (fixtures.signIn as any).reload = reloadMock;
+
+ render(, { wrapper });
+
+ await waitFor(() => {
+ expect(mockExecute).not.toHaveBeenCalled();
+ expect(reloadMock).toHaveBeenCalled();
+ });
+ });
+
+ it('treats protect_check_already_resolved as a soft success, reloads, and continues the flow', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ mockExecute.mockResolvedValue('proof-abc');
+ fixtures.signIn.submitProtectCheck.mockRejectedValue(
+ new ClerkAPIResponseError('Already resolved', {
+ data: [{ code: 'protect_check_already_resolved', message: 'Already resolved', long_message: '' }],
+ status: 400,
+ clerkTraceId: 'trace_123',
+ }),
+ );
+ const reloadMock = vi.fn().mockResolvedValue(fixtures.signIn);
+ (fixtures.signIn as any).reload = reloadMock;
+
+ render(, { wrapper });
+
+ await waitFor(() => {
+ expect(fixtures.signIn.submitProtectCheck).toHaveBeenCalled();
+ // Spec §5.3.4: reload to refresh stale local state before re-routing
+ expect(reloadMock).toHaveBeenCalled();
+ });
+ });
+
+ it('self-navigates on a chained challenge', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ mockExecute.mockResolvedValue('proof-1');
+ fixtures.signIn.submitProtectCheck.mockResolvedValue({
+ status: 'needs_protect_check',
+ protectCheck: {
+ status: 'pending',
+ token: 'challenge-token-2',
+ sdkUrl: 'https://protect.example.com/sdk.js',
+ },
+ createdSessionId: null,
+ } as unknown as SignInResource);
+
+ render(, { wrapper });
+
+ await waitFor(() => expect(fixtures.router.navigate).toHaveBeenCalledWith('.'));
+ });
+
+ it('aborts the SDK signal and does not submit when unmounted mid-challenge', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ let capturedSignal: AbortSignal | undefined;
+ let resolveProof: (token: string) => void;
+ mockExecute.mockImplementation((_protectCheck, _container, opts) => {
+ capturedSignal = opts?.signal;
+ return new Promise(resolve => {
+ resolveProof = resolve;
+ });
+ });
+
+ const { unmount } = render(, { wrapper });
+
+ await waitFor(() => expect(mockExecute).toHaveBeenCalled());
+ expect(capturedSignal?.aborted).toBe(false);
+
+ unmount();
+
+ expect(capturedSignal?.aborted).toBe(true);
+
+ // Even if the script later resolves (uncooperative SDK), submit must not fire
+ resolveProof!('late-proof');
+ await new Promise(resolve => setTimeout(resolve, 10));
+ expect(fixtures.signIn.submitProtectCheck).not.toHaveBeenCalled();
+ });
+
+ it('does not submit a proof token when the SDK challenge fails to execute', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ // executeProtectCheck always wraps load failures in ClerkRuntimeError; mirror that here
+ // so handleError's known-error check passes and the rejection is fully consumed.
+ mockExecute.mockRejectedValue(
+ new ClerkRuntimeError('Protect check script failed to load', {
+ code: 'protect_check_script_load_failed',
+ }),
+ );
+
+ render(, { wrapper });
+
+ await waitFor(() => expect(mockExecute).toHaveBeenCalled());
+ expect(fixtures.signIn.submitProtectCheck).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/ui/src/components/SignIn/handleCombinedFlowTransfer.ts b/packages/ui/src/components/SignIn/handleCombinedFlowTransfer.ts
index 5c2fa3a2726..caa2a834a9d 100644
--- a/packages/ui/src/components/SignIn/handleCombinedFlowTransfer.ts
+++ b/packages/ui/src/components/SignIn/handleCombinedFlowTransfer.ts
@@ -100,6 +100,7 @@ export function handleCombinedFlowTransfer({
signUp: res,
verifyEmailPath: 'create/verify-email-address',
verifyPhonePath: 'create/verify-phone-number',
+ protectCheckPath: 'create/protect-check',
handleComplete: () =>
clerk.setActive({
session: res.createdSessionId,
diff --git a/packages/ui/src/components/SignIn/handleProtectCheck.ts b/packages/ui/src/components/SignIn/handleProtectCheck.ts
new file mode 100644
index 00000000000..09ef351efbd
--- /dev/null
+++ b/packages/ui/src/components/SignIn/handleProtectCheck.ts
@@ -0,0 +1,12 @@
+import type { SignInResource } from '@clerk/shared/types';
+
+/**
+ * Detects whether a sign-in response is gated by Clerk Protect (per spec §2.2.2).
+ *
+ * The `protectCheck` field is the authoritative gating signal; new SDKs / newer servers
+ * also surface `status === 'needs_protect_check'`. Either signal triggers navigation
+ * to the protect-check route.
+ */
+export function isSignInProtectGated(signIn: SignInResource): boolean {
+ return !!signIn.protectCheck || signIn.status === 'needs_protect_check';
+}
diff --git a/packages/ui/src/components/SignIn/index.tsx b/packages/ui/src/components/SignIn/index.tsx
index f977eb229f9..08fb71c64d0 100644
--- a/packages/ui/src/components/SignIn/index.tsx
+++ b/packages/ui/src/components/SignIn/index.tsx
@@ -34,6 +34,7 @@ import { SignInAccountSwitcher } from './SignInAccountSwitcher';
import { SignInClientTrust } from './SignInClientTrust';
import { SignInFactorOne } from './SignInFactorOne';
import { SignInFactorTwo } from './SignInFactorTwo';
+import { SignInProtectCheck } from './SignInProtectCheck';
import { SignInSSOCallback } from './SignInSSOCallback';
import { SignInStart } from './SignInStart';
@@ -52,6 +53,12 @@ function SignInRoutes(): JSX.Element {
return (
+ !!clerk.client.signIn.protectCheck}
+ >
+
+
diff --git a/packages/ui/src/components/SignIn/shared.ts b/packages/ui/src/components/SignIn/shared.ts
index ec25432ea00..f7021c4b6c0 100644
--- a/packages/ui/src/components/SignIn/shared.ts
+++ b/packages/ui/src/components/SignIn/shared.ts
@@ -10,6 +10,8 @@ import { handleError } from '@/ui/utils/errorHandler';
import { useCoreSignIn, useSignInContext } from '../../contexts';
import { useSupportEmail } from '../../hooks/useSupportEmail';
+import { useRouter } from '../../router';
+import { isSignInProtectGated } from './handleProtectCheck';
function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise) {
const card = useCardState();
@@ -18,6 +20,7 @@ function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise
const supportEmail = useSupportEmail();
const { afterSignInUrl, navigateOnSetActive } = useSignInContext();
const { authenticateWithPasskey } = useCoreSignIn();
+ const { navigate } = useRouter();
useEffect(() => {
return () => {
@@ -28,6 +31,12 @@ function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise
return useCallback(async (...args: Parameters) => {
try {
const res = await authenticateWithPasskey(...args);
+ // Per spec §2.3 / §4: protect_check can fire on attempt_first_factor (which is what
+ // authenticateWithPasskey calls under the hood). Detect both the field and the
+ // SDK-version-gated status before dispatching on the underlying status.
+ if (isSignInProtectGated(res)) {
+ return navigate('../protect-check');
+ }
switch (res.status) {
case 'complete':
return setActive({
@@ -38,6 +47,8 @@ function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise
});
case 'needs_second_factor':
return onSecondFactor();
+ case 'needs_protect_check':
+ return navigate('../protect-check');
default:
return console.error(clerkInvalidFAPIResponse(res.status, supportEmail));
}
diff --git a/packages/ui/src/components/SignUp/SignUpContinue.tsx b/packages/ui/src/components/SignUp/SignUpContinue.tsx
index 55c339d67f0..2777d63324b 100644
--- a/packages/ui/src/components/SignUp/SignUpContinue.tsx
+++ b/packages/ui/src/components/SignUp/SignUpContinue.tsx
@@ -179,6 +179,7 @@ function SignUpContinueInternal() {
signUp: res,
verifyEmailPath: './verify-email-address',
verifyPhonePath: './verify-phone-number',
+ protectCheckPath: '../protect-check',
handleComplete: () =>
clerk.setActive({
session: res.createdSessionId,
diff --git a/packages/ui/src/components/SignUp/SignUpEmailLinkCard.tsx b/packages/ui/src/components/SignUp/SignUpEmailLinkCard.tsx
index 66207e4d6b7..292e31430f5 100644
--- a/packages/ui/src/components/SignUp/SignUpEmailLinkCard.tsx
+++ b/packages/ui/src/components/SignUp/SignUpEmailLinkCard.tsx
@@ -56,6 +56,7 @@ export const SignUpEmailLinkCard = () => {
continuePath: '../continue',
verifyEmailPath: '../verify-email-address',
verifyPhonePath: '../verify-phone-number',
+ protectCheckPath: '../protect-check',
handleComplete: () =>
setActive({
session: su.createdSessionId,
diff --git a/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx b/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
new file mode 100644
index 00000000000..7a418c48171
--- /dev/null
+++ b/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
@@ -0,0 +1,187 @@
+import { isClerkAPIResponseError } from '@clerk/shared/error';
+import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants';
+import { executeProtectCheck } from '@clerk/shared/internal/clerk-js/protectCheck';
+import { useClerk } from '@clerk/shared/react';
+import React from 'react';
+
+import { Card } from '@/ui/elements/Card';
+import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
+import { Header } from '@/ui/elements/Header';
+import { handleError } from '@/ui/utils/errorHandler';
+
+import { withRedirectToAfterSignUp } from '../../common';
+import { useCoreSignUp, useSignUpContext } from '../../contexts';
+import { Box, Col, descriptors, Flow, localizationKeys, useLocalizations } from '../../customizables';
+import { useRouter } from '../../router';
+import { completeSignUpFlow } from './util';
+
+function SignUpProtectCheckInternal(): JSX.Element {
+ const card = useCardState();
+ const { t } = useLocalizations();
+ const signUp = useCoreSignUp();
+ const { navigate } = useRouter();
+ const { setActive } = useClerk();
+ const ctx = useSignUpContext();
+ const { afterSignUpUrl, navigateOnSetActive } = ctx;
+
+ const containerRef = React.useRef(null);
+ const isRunningRef = React.useRef(false);
+ const [isRunning, setIsRunning] = React.useState(false);
+
+ React.useEffect(() => {
+ const protectCheck = signUp.protectCheck;
+ if (!protectCheck || isRunningRef.current) {
+ return;
+ }
+
+ // Cancellation: if the component unmounts (route change, navigation away) or the
+ // dependency changes (chained challenge with a new protectCheck reference), abort
+ // any in-flight script execution and skip downstream state updates.
+ const abortController = new AbortController();
+ let cancelled = false;
+
+ // Per spec §5.1.4: do not attempt to solve an expired challenge.
+ // Reload the resource so the server can mint a fresh challenge before re-routing,
+ // otherwise the local stale `signUp.protectCheck` would re-trigger this same effect
+ // and loop indefinitely with no user feedback.
+ if (protectCheck.expiresAt !== undefined && protectCheck.expiresAt < Date.now()) {
+ isRunningRef.current = true;
+ setIsRunning(true);
+ void (async () => {
+ try {
+ await signUp.reload();
+ } catch (err: any) {
+ if (!cancelled) {
+ handleError(err, [], card.setError);
+ }
+ } finally {
+ if (!cancelled) {
+ isRunningRef.current = false;
+ setIsRunning(false);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ abortController.abort();
+ isRunningRef.current = false;
+ };
+ }
+
+ const container = containerRef.current;
+ if (!container) {
+ return;
+ }
+
+ isRunningRef.current = true;
+ setIsRunning(true);
+
+ const continueAfter = async (resource = signUp) => {
+ if (cancelled) {
+ return;
+ }
+ await completeSignUpFlow({
+ signUp: resource,
+ verifyEmailPath: '../verify-email-address',
+ verifyPhonePath: '../verify-phone-number',
+ protectCheckPath: '.', // Self-navigate handles chained challenges (spec §3.2b, §4.2)
+ continuePath: '../continue',
+ handleComplete: () =>
+ setActive({
+ session: resource.createdSessionId,
+ navigate: async ({ session, decorateUrl }) => {
+ await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl, decorateUrl });
+ },
+ }),
+ navigate,
+ });
+ };
+
+ const runChallenge = async () => {
+ try {
+ const proofToken = await executeProtectCheck(protectCheck, container, {
+ signal: abortController.signal,
+ });
+ if (cancelled) {
+ return;
+ }
+
+ let updatedSignUp;
+ try {
+ updatedSignUp = await signUp.submitProtectCheck({ proofToken });
+ } catch (err) {
+ if (cancelled) {
+ return;
+ }
+ // Per spec §3.3, §5.3.4: protect_check_already_resolved is retry-safe.
+ // The server's resource state has moved past this gate; reload to pick up the
+ // fresh state, then continue routing based on the actual current status rather
+ // than stale local data.
+ if (isClerkAPIResponseError(err) && err.errors?.[0]?.code === ERROR_CODES.PROTECT_CHECK_ALREADY_RESOLVED) {
+ await signUp.reload();
+ if (cancelled) {
+ return;
+ }
+ await continueAfter();
+ return;
+ }
+ throw err;
+ }
+ if (cancelled) {
+ return;
+ }
+ await continueAfter(updatedSignUp);
+ } catch (err: any) {
+ if (cancelled) {
+ return;
+ }
+ handleError(err, [], card.setError);
+ } finally {
+ if (!cancelled) {
+ isRunningRef.current = false;
+ setIsRunning(false);
+ }
+ }
+ };
+
+ void runChallenge();
+
+ return () => {
+ cancelled = true;
+ abortController.abort();
+ // Reset the guard so the next mount / dep-change can re-run; this is what makes
+ // chained challenges work correctly across re-renders.
+ isRunningRef.current = false;
+ };
+ }, [signUp.protectCheck]);
+
+ return (
+
+
+
+
+
+
+
+ {card.error}
+
+
+ {isRunning && !card.error ? (
+ {t(localizationKeys('signUp.protectCheck.loading'))}
+ ) : null}
+
+
+
+
+
+ );
+}
+
+export const SignUpProtectCheck = withRedirectToAfterSignUp(withCardStateProvider(SignUpProtectCheckInternal));
diff --git a/packages/ui/src/components/SignUp/SignUpStart.tsx b/packages/ui/src/components/SignUp/SignUpStart.tsx
index 8400c8984ce..0f859c7073f 100644
--- a/packages/ui/src/components/SignUp/SignUpStart.tsx
+++ b/packages/ui/src/components/SignUp/SignUpStart.tsx
@@ -164,6 +164,7 @@ function SignUpStartInternal(): JSX.Element {
redirectUrlComplete,
verifyEmailPath: 'verify-email-address',
verifyPhonePath: 'verify-phone-number',
+ protectCheckPath: 'protect-check',
continuePath: 'continue',
handleComplete: () => {
removeClerkQueryParam('__clerk_ticket');
@@ -345,6 +346,7 @@ function SignUpStartInternal(): JSX.Element {
signUp: res,
verifyEmailPath: 'verify-email-address',
verifyPhonePath: 'verify-phone-number',
+ protectCheckPath: 'protect-check',
handleComplete: () =>
setActive({
session: res.createdSessionId,
diff --git a/packages/ui/src/components/SignUp/SignUpVerificationCodeForm.tsx b/packages/ui/src/components/SignUp/SignUpVerificationCodeForm.tsx
index 349b81962aa..1d03a1ff081 100644
--- a/packages/ui/src/components/SignUp/SignUpVerificationCodeForm.tsx
+++ b/packages/ui/src/components/SignUp/SignUpVerificationCodeForm.tsx
@@ -46,6 +46,7 @@ export const SignUpVerificationCodeForm = (props: SignInFactorOneCodeFormProps)
signUp: res,
verifyEmailPath: '../verify-email-address',
verifyPhonePath: '../verify-phone-number',
+ protectCheckPath: '../protect-check',
continuePath: '../continue',
handleComplete: () =>
setActive({
diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
new file mode 100644
index 00000000000..7d86fdcdbc5
--- /dev/null
+++ b/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
@@ -0,0 +1,171 @@
+import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error';
+import type { SignUpResource } from '@clerk/shared/types';
+import { waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render } from '@/test/utils';
+
+import { SignUpProtectCheck } from '../SignUpProtectCheck';
+
+vi.mock('@clerk/shared/internal/clerk-js/protectCheck', () => ({
+ executeProtectCheck: vi.fn(),
+}));
+
+import { executeProtectCheck } from '@clerk/shared/internal/clerk-js/protectCheck';
+
+const { createFixtures } = bindCreateFixtures('SignUp');
+
+const mockExecute = executeProtectCheck as unknown as ReturnType;
+
+beforeEach(() => {
+ mockExecute.mockReset();
+});
+
+describe('SignUpProtectCheck', () => {
+ it('renders verification UI', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.startSignUpWithProtectCheck();
+ });
+ mockExecute.mockReturnValue(new Promise(() => {})); // never resolves; keeps spinner
+
+ const { findByText } = render(, { wrapper });
+
+ expect(await findByText(/verifying your request/i)).toBeInTheDocument();
+ });
+
+ it('runs the SDK challenge with the URL and resource and submits the proof token', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignUpWithProtectCheck({ sdkUrl: 'https://protect.example.com/v1.js' });
+ });
+ mockExecute.mockResolvedValue('proof-abc');
+ fixtures.signUp.submitProtectCheck.mockResolvedValue({
+ status: 'complete',
+ protectCheck: null,
+ createdSessionId: 'sess_123',
+ } as unknown as SignUpResource);
+
+ render(, { wrapper });
+
+ await waitFor(() => {
+ expect(mockExecute).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sdkUrl: 'https://protect.example.com/v1.js',
+ token: 'challenge-token',
+ }),
+ expect.any(HTMLDivElement),
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
+ );
+ expect(fixtures.signUp.submitProtectCheck).toHaveBeenCalledWith({ proofToken: 'proof-abc' });
+ });
+ });
+
+ it('reloads the resource (does not run SDK) when expiresAt is in the past', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignUpWithProtectCheck({ expiresAt: Date.now() - 60_000 });
+ });
+ const reloadMock = vi.fn().mockResolvedValue(fixtures.signUp);
+ (fixtures.signUp as any).reload = reloadMock;
+
+ render(, { wrapper });
+
+ await waitFor(() => {
+ expect(mockExecute).not.toHaveBeenCalled();
+ expect(reloadMock).toHaveBeenCalled();
+ });
+ });
+
+ it('treats protect_check_already_resolved as a soft success, reloads, and continues the flow', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignUpWithProtectCheck();
+ });
+ mockExecute.mockResolvedValue('proof-abc');
+ fixtures.signUp.submitProtectCheck.mockRejectedValue(
+ new ClerkAPIResponseError('Already resolved', {
+ data: [{ code: 'protect_check_already_resolved', message: 'Already resolved', long_message: '' }],
+ status: 400,
+ clerkTraceId: 'trace_123',
+ }),
+ );
+ const reloadMock = vi.fn().mockResolvedValue(fixtures.signUp);
+ (fixtures.signUp as any).reload = reloadMock;
+
+ render(, { wrapper });
+
+ await waitFor(() => {
+ expect(fixtures.signUp.submitProtectCheck).toHaveBeenCalled();
+ // Spec §5.3.4: reload to refresh stale local state before re-routing
+ expect(reloadMock).toHaveBeenCalled();
+ });
+ });
+
+ it('re-runs on a chained challenge (self-navigates)', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignUpWithProtectCheck();
+ });
+ mockExecute.mockResolvedValue('proof-1');
+ // Submit returns a sign-up that still has protectCheck set (chained)
+ fixtures.signUp.submitProtectCheck.mockResolvedValue({
+ status: 'missing_requirements',
+ missingFields: ['protect_check'],
+ protectCheck: {
+ status: 'pending',
+ token: 'challenge-token-2',
+ sdkUrl: 'https://protect.example.com/sdk.js',
+ },
+ } as unknown as SignUpResource);
+
+ render(, { wrapper });
+
+ await waitFor(() => {
+ // Self-navigates to '.' to re-render with the new challenge
+ expect(fixtures.router.navigate).toHaveBeenCalledWith('.', { searchParams: expect.any(URLSearchParams) });
+ });
+ });
+
+ it('aborts the SDK signal and does not submit when unmounted mid-challenge', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignUpWithProtectCheck();
+ });
+ let capturedSignal: AbortSignal | undefined;
+ let resolveProof: (token: string) => void;
+ mockExecute.mockImplementation((_protectCheck, _container, opts) => {
+ capturedSignal = opts?.signal;
+ return new Promise(resolve => {
+ resolveProof = resolve;
+ });
+ });
+
+ const { unmount } = render(, { wrapper });
+
+ await waitFor(() => expect(mockExecute).toHaveBeenCalled());
+ expect(capturedSignal?.aborted).toBe(false);
+
+ unmount();
+
+ expect(capturedSignal?.aborted).toBe(true);
+
+ // Even if the script later resolves (uncooperative SDK), submit must not fire
+ resolveProof!('late-proof');
+ await new Promise(resolve => setTimeout(resolve, 10));
+ expect(fixtures.signUp.submitProtectCheck).not.toHaveBeenCalled();
+ });
+
+ it('does not submit a proof token when the SDK challenge fails to execute', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignUpWithProtectCheck();
+ });
+ // executeProtectCheck always wraps load failures in ClerkRuntimeError; mirror that here
+ // so handleError's known-error check passes and the rejection is fully consumed.
+ mockExecute.mockRejectedValue(
+ new ClerkRuntimeError('Protect check script failed to load', {
+ code: 'protect_check_script_load_failed',
+ }),
+ );
+
+ render(, { wrapper });
+
+ await waitFor(() => expect(mockExecute).toHaveBeenCalled());
+ expect(fixtures.signUp.submitProtectCheck).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/ui/src/components/SignUp/index.tsx b/packages/ui/src/components/SignUp/index.tsx
index b4028235421..269aaed9bb0 100644
--- a/packages/ui/src/components/SignUp/index.tsx
+++ b/packages/ui/src/components/SignUp/index.tsx
@@ -13,6 +13,7 @@ import { SignUpStartSolanaWalletsCard } from '@/ui/components/SignUp/SignUpStart
import { SignUpContinue } from './SignUpContinue';
import { SignUpEnterpriseConnections } from './SignUpEnterpriseConnections';
+import { SignUpProtectCheck } from './SignUpProtectCheck';
import { SignUpSSOCallback } from './SignUpSSOCallback';
import { SignUpStart } from './SignUpStart';
import { SignUpVerifyEmail } from './SignUpVerifyEmail';
@@ -34,6 +35,12 @@ function SignUpRoutes(): JSX.Element {
return (
+ !!clerk.client.signUp.protectCheck}
+ >
+
+
!!clerk.client.signUp.emailAddress}
diff --git a/packages/ui/src/elements/contexts/index.tsx b/packages/ui/src/elements/contexts/index.tsx
index cecccfe3d88..42007f6eea5 100644
--- a/packages/ui/src/elements/contexts/index.tsx
+++ b/packages/ui/src/elements/contexts/index.tsx
@@ -132,7 +132,8 @@ export type FlowMetadata = {
| 'chooseWallet'
| 'enterpriseConnections'
| 'organizationCreationDisabled'
- | 'methodSelectionMFA';
+ | 'methodSelectionMFA'
+ | 'protectCheck';
};
const [FlowMetadataCtx, useFlowMetadata] = createContextAndHook('FlowMetadata');
diff --git a/packages/ui/src/test/fixture-helpers.ts b/packages/ui/src/test/fixture-helpers.ts
index 460117de14a..1a657a0eec0 100644
--- a/packages/ui/src/test/fixture-helpers.ts
+++ b/packages/ui/src/test/fixture-helpers.ts
@@ -223,7 +223,33 @@ const createSignInFixtureHelpers = (baseClient: ClientJSON) => {
} as SignInJSON;
};
- return { startSignInWithEmailAddress, startSignInWithPhoneNumber, startSignInFactorTwo };
+ const startSignInWithProtectCheck = (params?: {
+ expiresAt?: number;
+ uiHints?: Record;
+ sdkUrl?: string;
+ }) => {
+ const { expiresAt, uiHints, sdkUrl = 'https://protect.example.com/sdk.js' } = params || {};
+ baseClient.sign_in = {
+ id: 'sia_2HseAXFGN12eqlwARPMxyyUa9o9',
+ status: 'needs_protect_check',
+ identifier: 'test@clerk.com',
+ supported_first_factors: [],
+ supported_second_factors: [],
+ first_factor_verification: null,
+ second_factor_verification: null,
+ created_session_id: null,
+ protect_check: {
+ status: 'pending',
+ token: 'challenge-token',
+ sdk_url: sdkUrl,
+ ...(expiresAt !== undefined && { expires_at: expiresAt }),
+ ...(uiHints !== undefined && { ui_hints: uiHints }),
+ },
+ user_data: { ...(createUserFixture() as any) },
+ } as SignInJSON;
+ };
+
+ return { startSignInWithEmailAddress, startSignInWithPhoneNumber, startSignInFactorTwo, startSignInWithProtectCheck };
};
const createSignUpFixtureHelpers = (baseClient: ClientJSON) => {
@@ -289,11 +315,32 @@ const createSignUpFixtureHelpers = (baseClient: ClientJSON) => {
} as SignUpJSON;
};
+ const startSignUpWithProtectCheck = (params?: {
+ expiresAt?: number;
+ uiHints?: Record;
+ sdkUrl?: string;
+ }) => {
+ const { expiresAt, uiHints, sdkUrl = 'https://protect.example.com/sdk.js' } = params || {};
+ baseClient.sign_up = {
+ id: 'sua_2HseAXFGN12eqlwARPMxyyUa9o9',
+ status: 'missing_requirements',
+ missing_fields: ['protect_check'],
+ protect_check: {
+ status: 'pending',
+ token: 'challenge-token',
+ sdk_url: sdkUrl,
+ ...(expiresAt !== undefined && { expires_at: expiresAt }),
+ ...(uiHints !== undefined && { ui_hints: uiHints }),
+ },
+ } as SignUpJSON;
+ };
+
return {
startSignUpWithEmailAddress,
startSignUpWithPhoneNumber,
startSignUpWithMissingLegalAccepted,
startSignUpWithMissingLegalAcceptedAndUnverifiedFields,
+ startSignUpWithProtectCheck,
};
};