diff --git a/.changeset/protect-check-support.md b/.changeset/protect-check-support.md
new file mode 100644
index 00000000000..9b700745f4e
--- /dev/null
+++ b/.changeset/protect-check-support.md
@@ -0,0 +1,22 @@
+---
+'@clerk/clerk-js': minor
+'@clerk/localizations': minor
+'@clerk/react': 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/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts
index 979cf6e24fa..aa86ccff245 100644
--- a/packages/clerk-js/src/core/__tests__/clerk.test.ts
+++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts
@@ -2123,6 +2123,97 @@ describe('Clerk singleton', () => {
expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/reset-password');
});
});
+
+ it('does not route a sign-up callback into a stale sign-in protect_check gate', async () => {
+ mockEnvironmentFetch.mockReturnValue(
+ Promise.resolve({
+ authConfig: {},
+ userSettings: mockUserSettings,
+ displayConfig: mockDisplayConfig,
+ isSingleSession: () => false,
+ isProduction: () => false,
+ isDevelopmentOrStaging: () => true,
+ onWindowLocationHost: () => false,
+ }),
+ );
+
+ // An abandoned sign-in keeps serializing its pending protect_check on the client.
+ const staleSignIn = new SignIn({
+ status: 'needs_protect_check',
+ identifier: 'user@example.com',
+ first_factor_verification: null,
+ second_factor_verification: null,
+ user_data: null,
+ created_session_id: null,
+ created_user_id: null,
+ protect_check: { status: 'pending', token: 'stale-token', sdk_url: 'https://example.com/sdk.js' },
+ } as any as SignInJSON);
+ const completeSignUp = new SignUp({ status: 'complete', created_session_id: 'sess_signup' } as any as SignUpJSON);
+ // The intent-driven reload at the top of the handler is a no-op here; keep the state stable.
+ (staleSignIn as any).reload = vi.fn().mockResolvedValue(staleSignIn);
+ (completeSignUp as any).reload = vi.fn().mockResolvedValue(completeSignUp);
+
+ mockClientFetch.mockReturnValue(
+ Promise.resolve({
+ signedInSessions: [],
+ signIn: staleSignIn,
+ signUp: completeSignUp,
+ }),
+ );
+
+ const mockSetActive = vi.fn();
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load(mockedLoadOptions);
+ sut.setActive = mockSetActive;
+
+ await sut.handleRedirectCallback({ reloadResource: 'signUp' });
+
+ await waitFor(() => {
+ // Completes the sign-up rather than routing into the stale sign-in's challenge.
+ expect(mockSetActive).toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalledWith('/sign-in#/protect-check');
+ });
+ });
+
+ it('routes a sign-in callback to the protect-check gate', async () => {
+ mockEnvironmentFetch.mockReturnValue(
+ Promise.resolve({
+ authConfig: {},
+ userSettings: mockUserSettings,
+ displayConfig: mockDisplayConfig,
+ isSingleSession: () => false,
+ isProduction: () => false,
+ isDevelopmentOrStaging: () => true,
+ onWindowLocationHost: () => false,
+ }),
+ );
+
+ mockClientFetch.mockReturnValue(
+ Promise.resolve({
+ signedInSessions: [],
+ signIn: new SignIn({
+ status: 'needs_protect_check',
+ identifier: 'user@example.com',
+ first_factor_verification: null,
+ second_factor_verification: null,
+ user_data: null,
+ created_session_id: null,
+ created_user_id: null,
+ protect_check: { status: 'pending', token: 'fresh-token', sdk_url: 'https://example.com/sdk.js' },
+ } as any as SignInJSON),
+ signUp: new SignUp(null),
+ }),
+ );
+
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load(mockedLoadOptions);
+
+ await sut.handleRedirectCallback();
+
+ await waitFor(() => {
+ expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/protect-check');
+ });
+ });
});
describe('.handleEmailLinkVerification()', () => {
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index b6c7b8d6734..c26b43d9df9 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -2352,6 +2352,7 @@ export class Clerk implements ClerkInterface {
externalAccountErrorCode: externalAccount.error?.code,
externalAccountSessionId: externalAccount.error?.meta?.sessionId,
sessionId: signUp.createdSessionId,
+ protectCheck: signUp.protectCheck,
};
const si = {
@@ -2360,6 +2361,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);
@@ -2383,6 +2385,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(
@@ -2396,7 +2402,18 @@ export class Clerk implements ClerkInterface {
),
);
+ const navigateToSignUpProtectCheck = makeNavigate(
+ buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }),
+ );
+
const navigateToNextStepSignUp = ({ missingFields }: { missingFields: SignUpField[] }) => {
+ // A protect-gated sign-up always carries 'protect_check' in missing_fields, so this gate
+ // check must run BEFORE the generic missing-fields short-circuit below — otherwise the
+ // OAuth/SAML callback would land on /continue instead of the challenge.
+ if (signUp.protectCheck || missingFields.includes('protect_check')) {
+ return navigateToSignUpProtectCheck();
+ }
+
if (missingFields.length) {
return navigateToContinueSignUp();
}
@@ -2415,6 +2432,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,
});
};
@@ -2451,11 +2469,35 @@ export class Clerk implements ClerkInterface {
});
}
+ // OAuth/SAML callbacks can resolve into a protect_check gate that surfaces on the next
+ // /v1/client read, so check for it here before continuing with the transfer logic below.
+ // Honor either the explicit `protectCheck` field or the `needs_protect_check` status override.
+ //
+ // Scope to the callback's intent: an abandoned sign-in keeps serializing its pending
+ // `protect_check` on the client for up to a day (and a later sign-up doesn't clear it in
+ // multi-session mode), so an unscoped check would route a *sign-up* callback into the stale
+ // sign-in's challenge. We only consult `si` here unless this is explicitly a sign-up callback.
+ // Transfers are unaffected: the `signIn.create({ transfer })` path below checks its own fresh
+ // response for the gate.
+ if (params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
+ return navigateToSignInProtectCheck();
+ }
+
+ // The sign-up resource can be gated the same way (e.g. a callback that resolves straight into a
+ // gated sign-up). Scope to the sign-up intent for the symmetric reason — a stale sign-up's gate
+ // shouldn't hijack a sign-in callback.
+ if (params.reloadResource !== 'signIn' && su.protectCheck) {
+ return navigateToSignUpProtectCheck();
+ }
+
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({
@@ -2702,6 +2744,8 @@ export class Clerk implements ClerkInterface {
strategy,
legalAccepted,
secondFactorUrl,
+ protectCheckUrl,
+ signUpProtectCheckUrl,
walletName,
}: ClerkAuthenticateWithWeb3Params): Promise => {
if (!this.client || !this.environment) {
@@ -2744,6 +2788,15 @@ export class Clerk implements ClerkInterface {
secondFactorUrl || buildURL({ base: displayConfig.signInUrl, hashPath: '/factor-two' }, { stringify: true }),
);
+ const navigateToSignInProtectCheck = makeNavigate(
+ protectCheckUrl || buildURL({ base: displayConfig.signInUrl, hashPath: '/protect-check' }, { stringify: true }),
+ );
+
+ const navigateToSignUpProtectCheck = makeNavigate(
+ signUpProtectCheckUrl ||
+ buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }),
+ );
+
const navigateToContinueSignUp = makeNavigate(
signUpContinueUrl ||
buildURL(
@@ -2756,6 +2809,7 @@ export class Clerk implements ClerkInterface {
);
let signInOrSignUp: SignInResource | SignUpResource;
+ let viaSignUp = false;
try {
signInOrSignUp = await this.client.signIn.authenticateWithWeb3({
identifier,
@@ -2765,6 +2819,7 @@ export class Clerk implements ClerkInterface {
});
} catch (err) {
if (isError(err, ERROR_CODES.FORM_IDENTIFIER_NOT_FOUND)) {
+ viaSignUp = true;
signInOrSignUp = await this.client.signUp.authenticateWithWeb3({
identifier,
generateSignature,
@@ -2777,7 +2832,10 @@ export class Clerk implements ClerkInterface {
if (
signUpContinueUrl &&
signInOrSignUp.status === 'missing_requirements' &&
- signInOrSignUp.verifications.web3Wallet.status === 'verified'
+ signInOrSignUp.verifications.web3Wallet.status === 'verified' &&
+ // A protect_check gate also surfaces as missing_requirements; don't skip past it into
+ // the continue step. The gate is handled by the sign-up protect-check route instead.
+ !signInOrSignUp.protectCheck
) {
await navigateToContinueSignUp();
}
@@ -2798,6 +2856,15 @@ export class Clerk implements ClerkInterface {
});
};
+ // A Clerk Protect challenge can gate the inline web3 attempt (no redirect happens, so the
+ // centralized _handleRedirectCallback check never runs). Route to the challenge before the
+ // status switch below, otherwise the user is stranded on the wallet step. The sign-up fallback
+ // gates as `missing_requirements` + `protectCheck`, so it has no status branch below either.
+ if (signInOrSignUp.protectCheck || signInOrSignUp.status === 'needs_protect_check') {
+ await (viaSignUp ? navigateToSignUpProtectCheck : navigateToSignInProtectCheck)();
+ return;
+ }
+
switch (signInOrSignUp.status) {
case 'needs_second_factor':
await navigateToFactorTwo();
diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts
index 3b19d06d56a..21647b1fb2d 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,22 @@ export class SignIn extends BaseResource implements SignInResource {
});
};
+ /**
+ * Submits a proof token to resolve a Clerk Protect challenge (`protect_check`) during sign-in.
+ *
+ * @param params - The proof token parameters.
+ * @param params.proofToken - The proof token produced by the Protect challenge SDK.
+ * @returns A promise resolving to the updated `SignIn` resource (gate cleared, a chained
+ * challenge, or the completed flow).
+ */
+ 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 +620,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 +689,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 +827,26 @@ class SignInFuture implements SignInFutureResource {
return this.#resource.secondFactorVerification;
}
+ get protectCheck() {
+ return this.#resource.protectCheck;
+ }
+
+ /**
+ * Submits a proof token to resolve a Clerk Protect challenge (`protect_check`) during sign-in.
+ *
+ * @param params - The proof token parameters.
+ * @param params.proofToken - The proof token produced by the Protect challenge SDK.
+ * @returns A promise resolving to `{ error }` — `null` on success, otherwise the encountered error.
+ */
+ 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 bccdfa48919..5b0973f9a57 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,22 @@ export class SignUp extends BaseResource implements SignUpResource {
});
};
+ /**
+ * Submits a proof token to resolve a Clerk Protect challenge (`protect_check`) during sign-up.
+ *
+ * @param params - The proof token parameters.
+ * @param params.proofToken - The proof token produced by the Protect challenge SDK.
+ * @returns A promise resolving to the updated `SignUp` resource (gate cleared, a chained
+ * challenge, or the completed flow).
+ */
+ 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 +513,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 +555,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 +814,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 (
@@ -1141,6 +1181,22 @@ class SignUpFuture implements SignUpFutureResource {
});
}
+ /**
+ * Submits a proof token to resolve a Clerk Protect challenge (`protect_check`) during sign-up.
+ *
+ * @param params - The proof token parameters.
+ * @param params.proofToken - The proof token produced by the Protect challenge SDK.
+ * @returns A promise resolving to `{ error }` — `null` on success, otherwise the encountered error.
+ */
+ 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, strategy: 'ticket', 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 2ecd83eec75..1431363cb1a 100644
--- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
+++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
@@ -2785,4 +2785,180 @@ describe('SignIn', () => {
});
});
});
+
+ describe('protectCheck', () => {
+ const originalFetch = BaseResource._fetch;
+
+ afterEach(() => {
+ // Restore the patched _fetch so the mock can't leak into any block added below.
+ BaseResource._fetch = originalFetch;
+ vi.clearAllMocks();
+ });
+
+ 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',
+ path: '/client/sign_ins/signin_123/protect_check',
+ 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 4e3f7911fe8..d129e065a73 100644
--- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts
+++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts
@@ -1788,4 +1788,247 @@ describe('SignUp', () => {
});
});
});
+
+ describe('protectCheck', () => {
+ const originalFetch = BaseResource._fetch;
+
+ afterEach(() => {
+ // Restore the patched _fetch so the mock can't leak into any block added below.
+ BaseResource._fetch = originalFetch;
+ vi.clearAllMocks();
+ });
+
+ 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',
+ path: '/client/sign_ups/signup_123/protect_check',
+ 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 518b9d21cbc..c0f7edb5a53 100644
--- a/packages/localizations/src/en-US.ts
+++ b/packages/localizations/src/en-US.ts
@@ -1374,6 +1374,12 @@ export const enUS: LocalizationResource = {
subtitle: 'Select a wallet below to sign in',
title: 'Sign in with Solana',
},
+ protectCheck: {
+ loading: 'Loading…',
+ retryButton: 'Try again',
+ subtitle: 'Please wait while we verify your request.',
+ title: 'Verifying your request',
+ },
},
signInEnterPasswordTitle: 'Enter your password',
signUp: {
@@ -1470,6 +1476,12 @@ export const enUS: LocalizationResource = {
subtitle: 'Select a wallet below to sign up',
title: 'Sign up with Solana',
},
+ protectCheck: {
+ loading: 'Loading…',
+ retryButton: 'Try again',
+ subtitle: 'Please wait while we verify your request.',
+ title: 'Verifying your request',
+ },
},
socialButtonsBlockButton: 'Continue with {{provider|titleize}}',
socialButtonsBlockButtonManyInView: '{{provider|titleize}}',
@@ -1590,6 +1602,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:
@@ -1659,6 +1672,16 @@ 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.",
+ protect_check_timed_out: "Verification didn't complete in time. Please try again.",
+ protect_check_unsupported_environment:
+ "Verification isn't supported in this environment. Please continue in a standard browser 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/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts
index a51dbdc5519..47832c63227 100644
--- a/packages/react/src/stateProxy.ts
+++ b/packages/react/src/stateProxy.ts
@@ -208,12 +208,16 @@ export class StateProxy implements State {
get canBeDiscarded() {
return gateProperty(target, 'canBeDiscarded', false);
},
+ get protectCheck() {
+ return gateProperty(target, 'protectCheck', null);
+ },
create: this.gateMethod(target, 'create'),
password: this.gateMethod(target, 'password'),
sso: this.gateMethod(target, 'sso'),
finalize: this.gateMethod(target, 'finalize'),
reset: this.gateMethod(target, 'reset'),
+ submitProtectCheck: this.gateMethod(target, 'submitProtectCheck'),
emailCode: this.wrapMethods(() => target().emailCode, ['sendCode', 'verifyCode'] as const),
emailLink: this.wrapStruct(
@@ -320,6 +324,9 @@ export class StateProxy implements State {
get canBeDiscarded() {
return gateProperty(target, 'canBeDiscarded', false);
},
+ get protectCheck() {
+ return gateProperty(target, 'protectCheck', null);
+ },
create: gateMethod(target, 'create'),
update: gateMethod(target, 'update'),
@@ -329,6 +336,7 @@ export class StateProxy implements State {
web3: gateMethod(target, 'web3'),
finalize: gateMethod(target, 'finalize'),
reset: gateMethod(target, 'reset'),
+ submitProtectCheck: gateMethod(target, 'submitProtectCheck'),
verifications: this.wrapStruct(
() => target().verifications,
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..bc019ed150a 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();
+ // The protect_check field is the authoritative gating signal. Sign-up also surfaces it
+ // via a 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..f9ca680fe97 100644
--- a/packages/shared/src/internal/clerk-js/constants.ts
+++ b/packages/shared/src/internal/clerk-js/constants.ts
@@ -45,6 +45,9 @@ 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',
+ PROTECT_CHECK_TIMED_OUT: 'protect_check_timed_out',
+ PROTECT_CHECK_UNSUPPORTED_ENVIRONMENT: 'protect_check_unsupported_environment',
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..9ec7bd7370a
--- /dev/null
+++ b/packages/shared/src/internal/clerk-js/protectCheck.ts
@@ -0,0 +1,152 @@
+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
+ * always returns an HTTPS URL, 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)
+ *
+ * Only the minimal 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/clerk.ts b/packages/shared/src/types/clerk.ts
index 1d7d9355e0c..9f50b9e4091 100644
--- a/packages/shared/src/types/clerk.ts
+++ b/packages/shared/src/types/clerk.ts
@@ -2717,6 +2717,15 @@ export interface ClerkAuthenticateWithWeb3Params {
* The URL to navigate to if [second factor](https://clerk.com/docs/guides/configure/auth-strategies/sign-up-sign-in-options#multi-factor-authentication) is required.
*/
secondFactorUrl?: string;
+ /**
+ * The URL to navigate to if a Clerk Protect challenge gates the sign-in flow.
+ */
+ protectCheckUrl?: string;
+ /**
+ * The URL to navigate to if a Clerk Protect challenge gates the sign-up flow (when the web3
+ * attempt falls back to sign-up).
+ */
+ signUpProtectCheckUrl?: string;
/**
* The name of the wallet to use for authentication.
*/
diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts
index dff688c85e1..ea9d733da0a 100644
--- a/packages/shared/src/types/json.ts
+++ b/packages/shared/src/types/json.ts
@@ -143,6 +143,21 @@ 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;
+ /**
+ * Unix epoch timestamp in **milliseconds** at which the challenge expires.
+ */
+ expires_at?: number;
+ ui_hints?: Record;
}
/**
diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts
index 423e5f45832..764e1e0cc39 100644
--- a/packages/shared/src/types/localization.ts
+++ b/packages/shared/src/types/localization.ts
@@ -409,6 +409,12 @@ export type __internal_LocalizationResource = {
subtitle: LocalizationValue;
noAvailableWallets: LocalizationValue;
};
+ protectCheck: {
+ title: LocalizationValue;
+ subtitle: LocalizationValue;
+ loading: LocalizationValue;
+ retryButton: LocalizationValue;
+ };
};
signIn: {
start: {
@@ -591,6 +597,12 @@ export type __internal_LocalizationResource = {
title: LocalizationValue;
subtitle: LocalizationValue;
};
+ protectCheck: {
+ title: LocalizationValue;
+ subtitle: LocalizationValue;
+ loading: LocalizationValue;
+ retryButton: LocalizationValue;
+ };
};
reverification: {
password: {
@@ -1980,11 +1992,20 @@ 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;
+ protect_check_timed_out: LocalizationValue;
+ protect_check_unsupported_environment: 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 3bb6798c6c8..597631e522c 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';
@@ -388,6 +389,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).
*
@@ -560,6 +566,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 }>;
+
/**
* Converts a sign-in with `status === 'complete'` into an active session. Will cause anything observing the session state (such as the [`useUser()`](https://clerk.com/docs/reference/hooks/use-user) 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 db5ca815f03..d8139b17006 100644
--- a/packages/shared/src/types/signUpCommon.ts
+++ b/packages/shared/src/types/signUpCommon.ts
@@ -25,8 +25,24 @@ import type { VerificationResource } from './verification';
/** @inline */
export type SignUpStatus = 'missing_requirements' | 'complete' | 'abandoned';
+export type ProtectCheckField = 'protect_check';
+
/** @inline */
-export type SignUpField = SignUpAttributeField | SignUpIdentificationField;
+export type SignUpField = SignUpAttributeField | SignUpIdentificationField | ProtectCheckField;
+
+export interface ProtectCheckResource {
+ /**
+ * Always `'pending'` when surfaced to clients.
+ */
+ status: 'pending';
+ token: string;
+ sdkUrl: string;
+ /**
+ * Unix epoch timestamp in **milliseconds** at which the challenge expires.
+ */
+ expiresAt?: number;
+ uiHints?: Record;
+}
export type PrepareVerificationParams =
| {
diff --git a/packages/shared/src/types/signUpFuture.ts b/packages/shared/src/types/signUpFuture.ts
index 27a47c5756f..39939b64ecd 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,
@@ -461,6 +467,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).
*
@@ -511,6 +522,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 }>;
+
/**
* Converts a sign-up with `status === 'complete'` into an active session. Will cause anything observing the session state (such as the [`useUser()`](https://clerk.com/docs/reference/hooks/use-user) hook) to update automatically.
*/
diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json
index ae8ea4a9c4e..035707e6a57 100644
--- a/packages/ui/bundlewatch.config.json
+++ b/packages/ui/bundlewatch.config.json
@@ -7,7 +7,7 @@
{ "path": "./dist/vendors*.js", "maxSize": "73KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "130KB" },
{ "path": "./dist/signin*.js", "maxSize": "16KB" },
- { "path": "./dist/signup*.js", "maxSize": "11KB" },
+ { "path": "./dist/signup*.js", "maxSize": "12KB" },
{ "path": "./dist/userprofile*.js", "maxSize": "16KB" },
{ "path": "./dist/organizationprofile*.js", "maxSize": "13KB" },
{ "path": "./dist/userbutton*.js", "maxSize": "3.5KB" },
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..ae2d53d1786 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 { navigateOnSignInProtectGate } 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 (navigateOnSignInProtectGate(res, navigate, '../protect-check')) {
+ return;
+ }
switch (status) {
case 'complete':
diff --git a/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneAlternativeChannelCodeForm.tsx
index c7a112ca08b..a16607838d6 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 { navigateOnSignInProtectGate } from './handleProtectCheck';
export type SignInFactorOneAlternativeChannelCodeCard = Pick<
VerificationCodeCardProps,
@@ -63,6 +64,10 @@ export const SignInFactorOneAlternativeChannelCodeForm = (props: SignInFactorOne
.then(async res => {
await resolve();
+ if (navigateOnSignInProtectGate(res, navigate, '../protect-check')) {
+ return;
+ }
+
switch (res.status) {
case 'complete':
return setActive({
diff --git a/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorOneCodeForm.tsx
index e057f588c29..0bd2eb1ff0f 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 { navigateOnSignInProtectGate } from './handleProtectCheck';
export type SignInFactorOneCodeCard = Pick<
VerificationCodeCardProps,
@@ -103,6 +104,10 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) =>
.then(async res => {
await resolve();
+ if (navigateOnSignInProtectGate(res, navigate, '../protect-check')) {
+ return;
+ }
+
switch (res.status) {
case 'complete':
return setActive({
diff --git a/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx
index 25a158a5044..339b6412306 100644
--- a/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx
+++ b/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx
@@ -14,6 +14,7 @@ import { Flow, localizationKeys, useLocalizations } from '../../customizables';
import { useCardState } from '../../elements/contexts';
import { useEmailLink } from '../../hooks/useEmailLink';
import { useRouter } from '../../router/RouteContext';
+import { navigateOnSignInProtectGate } from './handleProtectCheck';
type SignInFactorOneEmailLinkCardProps = Pick & {
factor: EmailLinkFactor;
@@ -71,6 +72,11 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard
};
const completeSignInFlow = async (si: SignInResource) => {
+ // An email-link verification can resolve into a protect_check gate; route to it before
+ // dispatching on the underlying status, otherwise the user is stranded on the link card.
+ if (navigateOnSignInProtectGate(si, navigate, '../protect-check')) {
+ return;
+ }
if (si.status === 'complete') {
return setActive({
session: si.createdSessionId,
diff --git a/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOnePasswordCard.tsx
index f4a453b4fe5..9029a2b8fcc 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 { navigateOnSignInProtectGate } 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 (navigateOnSignInProtectGate(res, navigate, '../protect-check')) {
+ return;
+ }
switch (res.status) {
case 'complete':
return setActive({
diff --git a/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx
index 4a94a0350b9..9ebe619d6ef 100644
--- a/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx
+++ b/packages/ui/src/components/SignIn/SignInFactorOneSolanaWalletsCard.tsx
@@ -67,6 +67,8 @@ const SignInFactorOneSolanaWalletsCardInner = () => {
customNavigate: router.navigate,
redirectUrl: ctx.afterSignInUrl || '/',
secondFactorUrl: 'factor-two',
+ protectCheckUrl: 'protect-check',
+ signUpProtectCheckUrl: ctx.isCombinedFlow ? 'create/protect-check' : undefined,
signUpContinueUrl: ctx.isCombinedFlow ? 'create/continue' : ctx.signUpContinueUrl,
strategy: 'web3_solana_signature',
walletName,
diff --git a/packages/ui/src/components/SignIn/SignInFactorTwoBackupCodeCard.tsx b/packages/ui/src/components/SignIn/SignInFactorTwoBackupCodeCard.tsx
index 119eb6f3308..ddd37690b03 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 { navigateOnSignInProtectGate } 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 (navigateOnSignInProtectGate(res, navigate, '../protect-check')) {
+ return;
+ }
switch (res.status) {
case 'complete':
if (isResettingPassword(res) && res.createdSessionId) {
diff --git a/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx b/packages/ui/src/components/SignIn/SignInFactorTwoCodeForm.tsx
index 0cf1ad32f57..46a72e3d9fb 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 { navigateOnSignInProtectGate } 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 (navigateOnSignInProtectGate(res, navigate, '../protect-check')) {
+ return;
+ }
switch (res.status) {
case 'complete':
if (isResettingPassword(res) && res.createdSessionId) {
diff --git a/packages/ui/src/components/SignIn/SignInProtectCheck.tsx b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx
new file mode 100644
index 00000000000..86de2bf885d
--- /dev/null
+++ b/packages/ui/src/components/SignIn/SignInProtectCheck.tsx
@@ -0,0 +1,139 @@
+import { useClerk } from '@clerk/shared/react';
+import type { SignInResource } from '@clerk/shared/types';
+
+import { Card } from '@/ui/elements/Card';
+import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
+import { Header } from '@/ui/elements/Header';
+
+import { withRedirectToAfterSignIn } from '../../common';
+import { useCoreSignIn, useSignInContext } from '../../contexts';
+import {
+ Box,
+ Button,
+ Col,
+ descriptors,
+ Flex,
+ Flow,
+ localizationKeys,
+ Spinner,
+ useLocalizations,
+} from '../../customizables';
+import { useProtectCheckRunner } from '../../hooks/useProtectCheckRunner';
+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).
+ *
+ * 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): Promise {
+ // Chained challenge — stay here and re-run the new challenge on next render. Both
+ // signals are checked: `protectCheck` is the authoritative field, and
+ // `'needs_protect_check'` is the SDK-version-gated status.
+ 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 { afterSignInUrl, navigateOnSetActive } = useSignInContext();
+
+ const { containerRef, isRunning, hasError, retry } = useProtectCheckRunner({
+ getProtectCheck: () => signIn.protectCheck,
+ getResource: () => signIn,
+ reload: () => signIn.reload(),
+ submitProtectCheck: params => signIn.submitProtectCheck(params),
+ // Routes on the resolved resource. This single path finalizes `complete` from both the normal
+ // success and the `protect_check_already_resolved` reload, so neither strands the user with an
+ // unactivated session.
+ onResolved: async (updatedSignIn, isCancelled) => {
+ if (isCancelled()) {
+ return;
+ }
+ if (updatedSignIn.status === 'complete' && updatedSignIn.createdSessionId) {
+ await setActive({
+ session: updatedSignIn.createdSessionId,
+ navigate: async ({ session, decorateUrl }) => {
+ await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl });
+ },
+ });
+ return;
+ }
+ await navigateNext(updatedSignIn, navigate);
+ },
+ });
+
+ return (
+
+
+
+
+
+
+
+ {card.error}
+
+
+ {isRunning && !hasError ? (
+
+
+ {t(localizationKeys('signIn.protectCheck.loading'))}
+
+ ) : null}
+ {hasError ? (
+
+ ) : null}
+
+
+
+
+
+ );
+}
+
+export const SignInProtectCheck = withRedirectToAfterSignIn(withCardStateProvider(SignInProtectCheckInternal));
diff --git a/packages/ui/src/components/SignIn/SignInSocialButtons.tsx b/packages/ui/src/components/SignIn/SignInSocialButtons.tsx
index ec5b7cc9170..36bc71ede4a 100644
--- a/packages/ui/src/components/SignIn/SignInSocialButtons.tsx
+++ b/packages/ui/src/components/SignIn/SignInSocialButtons.tsx
@@ -88,6 +88,8 @@ export const SignInSocialButtons = React.memo((props: SignInSocialButtonsProps)
signUpContinueUrl: ctx.isCombinedFlow ? 'create/continue' : ctx.signUpContinueUrl,
strategy,
secondFactorUrl: 'factor-two',
+ protectCheckUrl: 'protect-check',
+ signUpProtectCheckUrl: ctx.isCombinedFlow ? 'create/protect-check' : undefined,
})
.catch(err => web3CallbackErrorHandler(err, card.setError));
}}
diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx
index 558ca7e689f..a1edfbfd33a 100644
--- a/packages/ui/src/components/SignIn/SignInStart.tsx
+++ b/packages/ui/src/components/SignIn/SignInStart.tsx
@@ -39,6 +39,7 @@ import { useSupportEmail } from '../../hooks/useSupportEmail';
import { useTotalEnabledAuthMethods } from '../../hooks/useTotalEnabledAuthMethods';
import { useRouter } from '../../router';
import { handleCombinedFlowTransfer } from './handleCombinedFlowTransfer';
+import { navigateOnSignInProtectGate } from './handleProtectCheck';
import {
hasMultipleEnterpriseConnections,
SIGN_IN_RESET_PASSWORD_INTENT_PARAM,
@@ -56,7 +57,7 @@ const useAutoFillPasskey = () => {
const [isSupported, setIsSupported] = useState(false);
const { navigate } = useRouter();
const onSecondFactor = () => navigate('factor-two');
- const authenticateWithPasskey = useHandleAuthenticateWithPasskey(onSecondFactor);
+ const authenticateWithPasskey = useHandleAuthenticateWithPasskey(onSecondFactor, 'protect-check');
const { userSettings } = useEnvironment();
const { passkeySettings, attributes } = userSettings;
@@ -103,7 +104,7 @@ function SignInStartInternal(): JSX.Element {
*/
const { isWebAuthnAutofillSupported } = useAutoFillPasskey();
const onSecondFactor = () => navigate('factor-two');
- const authenticateWithPasskey = useHandleAuthenticateWithPasskey(onSecondFactor);
+ const authenticateWithPasskey = useHandleAuthenticateWithPasskey(onSecondFactor, 'protect-check');
const isWebSupported = isWebAuthnSupported();
const onlyPhoneNumberInitialValueExists =
@@ -228,6 +229,9 @@ function SignInStartInternal(): JSX.Element {
ticket: organizationTicket,
})
.then(res => {
+ if (navigateOnSignInProtectGate(res, navigate, 'protect-check')) {
+ return;
+ }
switch (res.status) {
case 'needs_first_factor': {
if (!hasOnlyEnterpriseSSOFirstFactors(res) || hasMultipleEnterpriseConnections(res.supportedFirstFactors)) {
@@ -383,6 +387,10 @@ function SignInStartInternal(): JSX.Element {
try {
const res = await safePasswordSignInForEnterpriseSSOInstance(signIn.create(buildSignInParams(fields)), fields);
+ if (navigateOnSignInProtectGate(res, navigate, 'protect-check')) {
+ return;
+ }
+
switch (res.status) {
case 'needs_identifier':
// Check if we need to initiate an enterprise sso flow
diff --git a/packages/ui/src/components/SignIn/__tests__/SignInFactorOne.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInFactorOne.test.tsx
index 5ce50ab88e5..614f98fee40 100644
--- a/packages/ui/src/components/SignIn/__tests__/SignInFactorOne.test.tsx
+++ b/packages/ui/src/components/SignIn/__tests__/SignInFactorOne.test.tsx
@@ -106,6 +106,34 @@ describe('SignInFactorOne', () => {
expect(fixtures.clerk.setActive).toHaveBeenCalled();
});
});
+
+ it('routes to the protect-check card when the first-factor attempt is gated by Clerk Protect', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withPassword();
+ f.withPreferredSignInStrategy({ strategy: 'password' });
+ f.startSignInWithEmailAddress({ supportPassword: true });
+ });
+ fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));
+ // A protect_check gate can fire on attempt_first_factor; the card must route to the gate
+ // instead of dispatching on the (now-irrelevant) underlying status.
+ fixtures.signIn.attemptFirstFactor.mockReturnValueOnce(
+ Promise.resolve({
+ status: 'needs_protect_check',
+ protectCheck: { status: 'pending', token: 'challenge-token', sdkUrl: 'https://protect.example.com/sdk.js' },
+ } as unknown as SignInResource),
+ );
+ const { userEvent } = render(, { wrapper });
+
+ await userEvent.type(screen.getByLabelText('Password'), '123456');
+ await userEvent.click(screen.getByText('Continue'));
+
+ await waitFor(() => {
+ expect(fixtures.router.navigate).toHaveBeenCalledWith('../protect-check');
+ // and must not fall through to a status-based route
+ expect(fixtures.clerk.setActive).not.toHaveBeenCalled();
+ });
+ });
});
describe('Selected First Factor Method', () => {
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..09c4390af8e
--- /dev/null
+++ b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
@@ -0,0 +1,257 @@
+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 { fireEvent, 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();
+ // 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();
+ });
+
+ it('finalizes the session when an already_resolved reload reveals a complete status', 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',
+ }),
+ );
+ // The reload surfaces a sign-in that has progressed straight to `complete`.
+ const reloadMock = vi.fn().mockImplementation(async () => {
+ (fixtures.signIn as any).status = 'complete';
+ (fixtures.signIn as any).createdSessionId = 'sess_done';
+ (fixtures.signIn as any).protectCheck = null;
+ return fixtures.signIn;
+ });
+ (fixtures.signIn as any).reload = reloadMock;
+
+ render(, { wrapper });
+
+ // Must finalize (setActive), not bounce to the start form with an unactivated session.
+ await waitFor(() => {
+ expect(reloadMock).toHaveBeenCalled();
+ expect(fixtures.clerk.setActive).toHaveBeenCalled();
+ });
+ });
+
+ it('shows a retry control after a failure and re-runs the challenge when clicked', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignInWithProtectCheck();
+ });
+ mockExecute
+ .mockRejectedValueOnce(
+ new ClerkRuntimeError('Protect check script failed to load', { code: 'protect_check_script_load_failed' }),
+ )
+ .mockResolvedValue('proof-retry');
+ fixtures.signIn.submitProtectCheck.mockResolvedValue({
+ status: 'needs_first_factor',
+ protectCheck: null,
+ createdSessionId: null,
+ } as unknown as SignInResource);
+
+ const { findByRole } = render(, { wrapper });
+
+ // Target the button by role: the error message itself also contains "try again".
+ const retryButton = await findByRole('button', { name: /try again/i });
+ fireEvent.click(retryButton);
+
+ await waitFor(() => {
+ expect(mockExecute).toHaveBeenCalledTimes(2);
+ expect(fixtures.signIn.submitProtectCheck).toHaveBeenCalledWith({ proofToken: 'proof-retry' });
+ });
+ });
+});
diff --git a/packages/ui/src/components/SignIn/__tests__/handleProtectCheck.test.ts b/packages/ui/src/components/SignIn/__tests__/handleProtectCheck.test.ts
new file mode 100644
index 00000000000..154bd70731d
--- /dev/null
+++ b/packages/ui/src/components/SignIn/__tests__/handleProtectCheck.test.ts
@@ -0,0 +1,70 @@
+import type { ProtectCheckResource, SignInResource } from '@clerk/shared/types';
+import { describe, expect, it, vi } from 'vitest';
+
+import { isSignInProtectGated, navigateOnSignInProtectGate } from '../handleProtectCheck';
+
+const PENDING_CHECK: ProtectCheckResource = {
+ status: 'pending',
+ token: 'challenge-token',
+ sdkUrl: 'https://protect.example.com/sdk.js',
+};
+
+const asSignIn = (partial: Partial): SignInResource => partial as unknown as SignInResource;
+
+describe('isSignInProtectGated', () => {
+ it('is true when the protectCheck field is present', () => {
+ expect(isSignInProtectGated(asSignIn({ status: 'needs_first_factor', protectCheck: PENDING_CHECK }))).toBe(true);
+ });
+
+ it("is true when the status is 'needs_protect_check' (SDK-version-gated signal)", () => {
+ expect(isSignInProtectGated(asSignIn({ status: 'needs_protect_check', protectCheck: null }))).toBe(true);
+ });
+
+ it('is false when neither signal is present', () => {
+ expect(isSignInProtectGated(asSignIn({ status: 'needs_first_factor', protectCheck: null }))).toBe(false);
+ });
+});
+
+describe('navigateOnSignInProtectGate', () => {
+ it('navigates to the provided path and returns true when gated by the protectCheck field', () => {
+ const navigate = vi.fn().mockResolvedValue(undefined);
+ const handled = navigateOnSignInProtectGate(
+ asSignIn({ status: 'needs_first_factor', protectCheck: PENDING_CHECK }),
+ navigate,
+ '../protect-check',
+ );
+
+ expect(handled).toBe(true);
+ expect(navigate).toHaveBeenCalledWith('../protect-check');
+ });
+
+ it("navigates and returns true when gated by the 'needs_protect_check' status", () => {
+ const navigate = vi.fn().mockResolvedValue(undefined);
+ const handled = navigateOnSignInProtectGate(
+ asSignIn({ status: 'needs_protect_check', protectCheck: null }),
+ navigate,
+ 'protect-check',
+ );
+
+ expect(handled).toBe(true);
+ expect(navigate).toHaveBeenCalledWith('protect-check');
+ });
+
+ it('honors the per-caller path verbatim (index mount vs factor mount)', () => {
+ const navigate = vi.fn().mockResolvedValue(undefined);
+ navigateOnSignInProtectGate(asSignIn({ protectCheck: PENDING_CHECK }), navigate, 'protect-check');
+ expect(navigate).toHaveBeenCalledWith('protect-check');
+ });
+
+ it('does not navigate and returns false when not gated', () => {
+ const navigate = vi.fn().mockResolvedValue(undefined);
+ const handled = navigateOnSignInProtectGate(
+ asSignIn({ status: 'needs_first_factor', protectCheck: null }),
+ navigate,
+ '../protect-check',
+ );
+
+ expect(handled).toBe(false);
+ expect(navigate).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..1c061cc66b8
--- /dev/null
+++ b/packages/ui/src/components/SignIn/handleProtectCheck.ts
@@ -0,0 +1,38 @@
+import type { SignInResource } from '@clerk/shared/types';
+
+/**
+ * Detects whether a sign-in response is gated by Clerk Protect.
+ *
+ * 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';
+}
+
+/**
+ * Single choke point for routing a Clerk Protect gate during sign-in.
+ *
+ * Every sign-in operation that returns a `SignInResource` (create, attempt/prepare first/second
+ * factor, passkey, reset-password, web3, …) can be gated mid-flow, and a missed call site strands
+ * the user at the previous step. Funnel them all through this helper: call it right after the
+ * operation resolves and `return` when it returns `true`, before dispatching on `signIn.status`.
+ *
+ * The `protectCheckPath` is supplied per call site because the prebuilt UI mounts sign-in steps at
+ * different route depths — `SignInStart` (index) reaches the route at `'protect-check'`, the factor
+ * cards reach it at `'../protect-check'`.
+ *
+ * @returns `true` if the response was gated and navigation was issued (caller should stop).
+ */
+export function navigateOnSignInProtectGate(
+ signIn: SignInResource,
+ navigate: (to: string) => Promise,
+ protectCheckPath: string,
+): boolean {
+ if (isSignInProtectGated(signIn)) {
+ void navigate(protectCheckPath);
+ return true;
+ }
+ return false;
+}
diff --git a/packages/ui/src/components/SignIn/index.tsx b/packages/ui/src/components/SignIn/index.tsx
index f977eb229f9..32c53458c05 100644
--- a/packages/ui/src/components/SignIn/index.tsx
+++ b/packages/ui/src/components/SignIn/index.tsx
@@ -22,6 +22,7 @@ import { normalizeRoutingOptions } from '@/utils/normalizeRoutingOptions';
import {
LazySignUpContinue,
+ LazySignUpProtectCheck,
LazySignUpSSOCallback,
LazySignUpStart,
LazySignUpVerifyEmail,
@@ -34,6 +35,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 +54,12 @@ function SignInRoutes(): JSX.Element {
return (
+ !!clerk.client.signIn.protectCheck}
+ >
+
+
@@ -96,6 +104,12 @@ function SignInRoutes(): JSX.Element {
{signInContext.isCombinedFlow && (
+ !!clerk.client.signUp.protectCheck}
+ >
+
+
!!clerk.client.signUp.emailAddress}
@@ -130,6 +144,13 @@ function SignInRoutes(): JSX.Element {
/>
+ !!clerk.client.signUp.protectCheck}
+ >
+ {/* Under `create/continue`, the continue index is `..`, not `../continue`. */}
+
+
!!clerk.client.signUp.emailAddress}
diff --git a/packages/ui/src/components/SignIn/lazy-sign-up.ts b/packages/ui/src/components/SignIn/lazy-sign-up.ts
index 536604baa6b..bf503d90450 100644
--- a/packages/ui/src/components/SignIn/lazy-sign-up.ts
+++ b/packages/ui/src/components/SignIn/lazy-sign-up.ts
@@ -7,6 +7,7 @@ const LazySignUpVerifyEmail = lazy(() => preloadSignUp().then(m => ({ default: m
const LazySignUpStart = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpStart })));
const LazySignUpSSOCallback = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpSSOCallback })));
const LazySignUpContinue = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpContinue })));
+const LazySignUpProtectCheck = lazy(() => preloadSignUp().then(m => ({ default: m.SignUpProtectCheck })));
const lazyCompleteSignUpFlow = () =>
import(/* webpackChunkName: "signup" */ '../SignUp/util').then(m => m.completeSignUpFlow);
@@ -18,5 +19,6 @@ export {
LazySignUpStart,
LazySignUpSSOCallback,
LazySignUpContinue,
+ LazySignUpProtectCheck,
lazyCompleteSignUpFlow,
};
diff --git a/packages/ui/src/components/SignIn/shared.ts b/packages/ui/src/components/SignIn/shared.ts
index 0d16c3589cb..33cb2026be8 100644
--- a/packages/ui/src/components/SignIn/shared.ts
+++ b/packages/ui/src/components/SignIn/shared.ts
@@ -2,7 +2,7 @@ import { isClerkRuntimeError, isUserLockedError } from '@clerk/shared/error';
import { clerkInvalidFAPIResponse } from '@clerk/shared/internal/clerk-js/errors';
import { __internal_WebAuthnAbortService } from '@clerk/shared/internal/clerk-js/passkeys';
import { useClerk } from '@clerk/shared/react';
-import type { EnterpriseSSOFactor, SignInFirstFactor } from '@clerk/shared/types';
+import type { EnterpriseSSOFactor, SignInFirstFactor, SignInResource } from '@clerk/shared/types';
import { useCallback, useEffect } from 'react';
import { useCardState } from '@/ui/elements/contexts';
@@ -10,17 +10,30 @@ import { handleError } from '@/ui/utils/errorHandler';
import { useCoreSignIn, useSignInContext } from '../../contexts';
import { useSupportEmail } from '../../hooks/useSupportEmail';
+import { useRouter } from '../../router';
+import { navigateOnSignInProtectGate } from './handleProtectCheck';
/** Search param set when navigating from the start page "Forgot password?" action. */
export const SIGN_IN_RESET_PASSWORD_INTENT_PARAM = '__clerk_reset_password';
-function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise) {
+/**
+ * @param onSecondFactor - invoked when the passkey attempt resolves to a second factor.
+ * @param protectCheckPath - route to the protect-check card relative to the caller's mount.
+ * Defaults to the factor-one mount (`'../protect-check'`); `SignInStart` (index route) must pass
+ * `'protect-check'`, otherwise an autofill-triggered, gated passkey sign-in dead-ends at the app
+ * root instead of `/sign-in/protect-check`.
+ */
+function useHandleAuthenticateWithPasskey(
+ onSecondFactor: () => Promise,
+ protectCheckPath = '../protect-check',
+): (...args: Parameters) => Promise {
const card = useCardState();
// @ts-expect-error -- private method for the time being
const { setActive, __internal_navigateWithError } = useClerk();
const supportEmail = useSupportEmail();
const { afterSignInUrl, navigateOnSetActive } = useSignInContext();
const { authenticateWithPasskey } = useCoreSignIn();
+ const { navigate } = useRouter();
useEffect(() => {
return () => {
@@ -31,6 +44,11 @@ function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise
return useCallback(async (...args: Parameters) => {
try {
const res = await authenticateWithPasskey(...args);
+ // A protect_check gate can fire on attempt_first_factor, which is what
+ // authenticateWithPasskey calls under the hood.
+ if (navigateOnSignInProtectGate(res, navigate, protectCheckPath)) {
+ return;
+ }
switch (res.status) {
case 'complete':
return setActive({
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..67b9f5615e7
--- /dev/null
+++ b/packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
@@ -0,0 +1,137 @@
+import { useClerk } from '@clerk/shared/react';
+import type { SignUpProps, SignUpResource } from '@clerk/shared/types';
+import type { ComponentType } from 'react';
+
+import { Card } from '@/ui/elements/Card';
+import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
+import { Header } from '@/ui/elements/Header';
+
+import { withRedirectToAfterSignUp } from '../../common';
+import { useCoreSignUp, useSignUpContext } from '../../contexts';
+import {
+ Box,
+ Button,
+ Col,
+ descriptors,
+ Flex,
+ Flow,
+ localizationKeys,
+ Spinner,
+ useLocalizations,
+} from '../../customizables';
+import { useProtectCheckRunner } from '../../hooks/useProtectCheckRunner';
+import { useRouter } from '../../router';
+import { completeSignUpFlow } from './util';
+
+/**
+ * Continuation paths default to the standalone `/sign-up/protect-check` mount. When the card is
+ * mounted deeper (e.g. `continue/protect-check` or the combined-flow `create/.../protect-check`),
+ * the nested route passes overrides so a resolved gate routes within the correct subtree instead
+ * of dead-ending. The verify/self paths resolve correctly from every mount; only `continuePath`
+ * differs (the `continue` index is `..`, not `../continue`, once we're already under `continue`).
+ */
+type SignUpProtectCheckProps = Partial & {
+ verifyEmailPath?: string;
+ verifyPhonePath?: string;
+ continuePath?: string;
+ protectCheckPath?: string;
+};
+
+function SignUpProtectCheckInternal({
+ verifyEmailPath = '../verify-email-address',
+ verifyPhonePath = '../verify-phone-number',
+ continuePath = '../continue',
+ protectCheckPath = '.',
+}: SignUpProtectCheckProps = {}): JSX.Element {
+ const card = useCardState();
+ const { t } = useLocalizations();
+ const signUp = useCoreSignUp();
+ const { navigate } = useRouter();
+ const { setActive } = useClerk();
+ const { afterSignUpUrl, navigateOnSetActive } = useSignUpContext();
+
+ const { containerRef, isRunning, hasError, retry } = useProtectCheckRunner({
+ getProtectCheck: () => signUp.protectCheck,
+ getResource: () => signUp,
+ reload: () => signUp.reload(),
+ submitProtectCheck: params => signUp.submitProtectCheck(params),
+ // Routes on the resolved resource. `completeSignUpFlow` handles the `complete` case (via
+ // `handleComplete`) as well as routing to the next missing-field / verification / chained-
+ // challenge step, so both the normal success and the `protect_check_already_resolved` reload
+ // land correctly.
+ onResolved: async (updatedSignUp, isCancelled) => {
+ if (isCancelled()) {
+ return;
+ }
+ await completeSignUpFlow({
+ signUp: updatedSignUp,
+ verifyEmailPath,
+ verifyPhonePath,
+ protectCheckPath, // Defaults to '.' so a chained challenge re-runs this same route
+ continuePath,
+ handleComplete: () =>
+ setActive({
+ session: updatedSignUp.createdSessionId,
+ navigate: async ({ session, decorateUrl }) => {
+ await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl, decorateUrl });
+ },
+ }),
+ navigate,
+ });
+ },
+ });
+
+ return (
+
+
+
+
+
+
+
+ {card.error}
+
+
+ {isRunning && !hasError ? (
+
+
+ {t(localizationKeys('signUp.protectCheck.loading'))}
+
+ ) : null}
+ {hasError ? (
+
+ ) : null}
+
+
+
+
+
+ );
+}
+
+// The redirect HOC widens props back to the shared component-props union; re-expose the path
+// overrides so nested route mounts (continue/protect-check, create/continue/protect-check) can
+// pass them.
+export const SignUpProtectCheck = withRedirectToAfterSignUp(
+ withCardStateProvider(SignUpProtectCheckInternal),
+) as ComponentType;
diff --git a/packages/ui/src/components/SignUp/SignUpStart.tsx b/packages/ui/src/components/SignUp/SignUpStart.tsx
index e719bbaf5c3..0c4637b03f3 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');
@@ -353,6 +354,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..cd0f9196935
--- /dev/null
+++ b/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
@@ -0,0 +1,227 @@
+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 { fireEvent, 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();
+ // 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();
+ });
+
+ it('finalizes the session when an already_resolved reload reveals a complete status', 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().mockImplementation(async () => {
+ (fixtures.signUp as any).status = 'complete';
+ (fixtures.signUp as any).createdSessionId = 'sess_done';
+ (fixtures.signUp as any).protectCheck = null;
+ (fixtures.signUp as any).missingFields = [];
+ return fixtures.signUp;
+ });
+ (fixtures.signUp as any).reload = reloadMock;
+
+ render(, { wrapper });
+
+ await waitFor(() => {
+ expect(reloadMock).toHaveBeenCalled();
+ expect(fixtures.clerk.setActive).toHaveBeenCalled();
+ });
+ });
+
+ it('shows a retry control after a failure and re-runs the challenge when clicked', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.startSignUpWithProtectCheck();
+ });
+ mockExecute
+ .mockRejectedValueOnce(
+ new ClerkRuntimeError('Protect check script failed to load', { code: 'protect_check_script_load_failed' }),
+ )
+ .mockResolvedValue('proof-retry');
+ fixtures.signUp.submitProtectCheck.mockResolvedValue({
+ status: 'complete',
+ protectCheck: null,
+ createdSessionId: 'sess_123',
+ } as unknown as SignUpResource);
+
+ const { findByRole } = render(, { wrapper });
+
+ // Target the button by role: the error message itself also contains "try again".
+ const retryButton = await findByRole('button', { name: /try again/i });
+ fireEvent.click(retryButton);
+
+ await waitFor(() => {
+ expect(mockExecute).toHaveBeenCalledTimes(2);
+ expect(fixtures.signUp.submitProtectCheck).toHaveBeenCalledWith({ proofToken: 'proof-retry' });
+ });
+ });
+});
diff --git a/packages/ui/src/components/SignUp/index.tsx b/packages/ui/src/components/SignUp/index.tsx
index b4028235421..99f9ea3197f 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}
@@ -67,6 +74,13 @@ function SignUpRoutes(): JSX.Element {
/>
+ !!clerk.client.signUp.protectCheck}
+ >
+ {/* Under `continue`, the continue index is `..`, not `../continue`. */}
+
+
!!clerk.client.signUp.emailAddress}
@@ -138,4 +152,4 @@ export const SignUpModal = (props: SignUpModalProps): JSX.Element => {
);
};
-export { SignUpContinue, SignUpSSOCallback, SignUpStart, SignUpVerifyEmail, SignUpVerifyPhone };
+export { SignUpContinue, SignUpProtectCheck, SignUpSSOCallback, SignUpStart, SignUpVerifyEmail, SignUpVerifyPhone };
diff --git a/packages/ui/src/elements/contexts/index.tsx b/packages/ui/src/elements/contexts/index.tsx
index 8da8277458c..5da1770c03e 100644
--- a/packages/ui/src/elements/contexts/index.tsx
+++ b/packages/ui/src/elements/contexts/index.tsx
@@ -140,7 +140,8 @@ export type FlowMetadata = {
| 'configureCreateApp'
| 'configureMapAttributes'
| 'testSso'
- | 'ssoConfirmation';
+ | 'ssoConfirmation'
+ | 'protectCheck';
};
const [FlowMetadataCtx, useFlowMetadata] = createContextAndHook('FlowMetadata');
diff --git a/packages/ui/src/hooks/useProtectCheckRunner.ts b/packages/ui/src/hooks/useProtectCheckRunner.ts
new file mode 100644
index 00000000000..fea914b30d4
--- /dev/null
+++ b/packages/ui/src/hooks/useProtectCheckRunner.ts
@@ -0,0 +1,239 @@
+import { ClerkRuntimeError, 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 type { ProtectCheckResource } from '@clerk/shared/types';
+import React from 'react';
+
+import { useCardState } from '@/ui/elements/contexts';
+import { handleError } from '@/ui/utils/errorHandler';
+
+/**
+ * A plain GET reload does not re-mint a protect_check challenge server-side, so an expired
+ * challenge would otherwise reload → still expired → reload again, forever. Cap the attempts
+ * and surface an error instead of spinning silently.
+ *
+ * NOTE: who re-mints an expired challenge on read (FAPI vs. re-running the gated step) is still
+ * being decided with the clerk_go team; this cap is the defensive floor until that lands.
+ */
+const MAX_EXPIRED_RELOADS = 2;
+
+/** Upper bound on how long we wait for the challenge SDK to settle before failing loud. */
+const PROTECT_CHECK_SCRIPT_TIMEOUT_MS = 60_000;
+
+export interface ProtectCheckRunnerParams {
+ /**
+ * Reads the current protect_check off the resource. Called fresh on each effect run because
+ * `fromJSON` mints a new object on every resource update — we key the effect on the token, not
+ * this reference, so an unrelated refresh doesn't restart the challenge under the user.
+ */
+ getProtectCheck: () => ProtectCheckResource | null | undefined;
+ /** Returns the live resource, used to route after a reload (which mutates it in place). */
+ getResource: () => TResource;
+ /** Reloads the underlying resource (GET) to pick up fresh server state. */
+ reload: () => Promise;
+ /** Submits the proof token; resolves to the updated resource. */
+ submitProtectCheck: (params: { proofToken: string }) => Promise;
+ /**
+ * Continues the flow once the gate clears (or a chained challenge / already-resolved is
+ * detected). Receives the resource to route on (the `submitProtectCheck` result, or the live
+ * resource after a reload) and must finalize (`setActive`) the `complete` case itself.
+ * `isCancelled` lets the continuation bail if the component unmounted mid-await.
+ */
+ onResolved: (resource: TResource, isCancelled: () => boolean) => Promise;
+}
+
+export interface ProtectCheckRunner {
+ containerRef: React.MutableRefObject;
+ isRunning: boolean;
+ /** Whether the card is currently showing a (recoverable) error. */
+ hasError: boolean;
+ /** Clears the error and re-runs the challenge from scratch. */
+ retry: () => void;
+}
+
+/**
+ * Shared driver for the `` and `` cards. Both run the
+ * exact same lifecycle — load + execute the Protect SDK, submit the proof token, continue the flow
+ * — so the abort/cancel/expiry/timeout/no-RHC handling lives here once instead of being duplicated
+ * (and drifting) across the two components.
+ *
+ * Must be called from within a `CardStateProvider`.
+ */
+export function useProtectCheckRunner(params: ProtectCheckRunnerParams): ProtectCheckRunner {
+ const card = useCardState();
+
+ const containerRef = React.useRef(null);
+ const isRunningRef = React.useRef(false);
+ const reloadCountRef = React.useRef(0);
+ const [isRunning, setIsRunning] = React.useState(false);
+ const [retryNonce, setRetryNonce] = React.useState(0);
+
+ // Keep the latest callbacks without re-running the effect when the caller re-renders.
+ const paramsRef = React.useRef(params);
+ paramsRef.current = params;
+
+ const token = params.getProtectCheck()?.token;
+
+ React.useEffect(() => {
+ const { getProtectCheck, getResource, reload, submitProtectCheck, onResolved } = paramsRef.current;
+ const protectCheck = getProtectCheck();
+ if (!protectCheck || isRunningRef.current) {
+ return;
+ }
+
+ const abortController = new AbortController();
+ let cancelled = false;
+ const isCancelled = () => cancelled;
+
+ const cleanup = () => {
+ cancelled = true;
+ abortController.abort();
+ // Reset the guard so the next mount / token change / retry can re-run; this is what makes
+ // chained challenges work correctly across re-renders.
+ isRunningRef.current = false;
+ };
+
+ const failWith = (code: string, message: string) => {
+ isRunningRef.current = false;
+ setIsRunning(false);
+ handleError(new ClerkRuntimeError(message, { code }), [], card.setError);
+ };
+
+ // Fail closed in no-RHC builds (chrome extension / clerk.no-rhc.js): the gate requires a
+ // remote `import(sdk_url)` we must not perform there. This guard MUST live in the component
+ // layer — `executeProtectCheck` is in `@clerk/shared`, compiled once with the flag hard-coded
+ // `false`, so a guard there would never trip.
+ if (__BUILD_DISABLE_RHC__) {
+ failWith(
+ ERROR_CODES.PROTECT_CHECK_UNSUPPORTED_ENVIRONMENT,
+ 'Protect verification is not supported in this environment',
+ );
+ return;
+ }
+
+ // Expired challenge: reload once to pick up a fresh challenge if the server minted one, but
+ // cap the attempts (see MAX_EXPIRED_RELOADS) so a server that returns the same expired
+ // challenge on read can't spin us forever.
+ if (protectCheck.expiresAt !== undefined && protectCheck.expiresAt < Date.now()) {
+ if (reloadCountRef.current >= MAX_EXPIRED_RELOADS) {
+ failWith(ERROR_CODES.PROTECT_CHECK_TIMED_OUT, 'Protect verification expired');
+ return;
+ }
+ reloadCountRef.current += 1;
+ isRunningRef.current = true;
+ setIsRunning(true);
+ void (async () => {
+ try {
+ await reload();
+ if (cancelled) {
+ return;
+ }
+ const refreshed = getProtectCheck();
+ const stillExpired = !!refreshed && refreshed.expiresAt !== undefined && refreshed.expiresAt < Date.now();
+ if (stillExpired) {
+ // The server didn't re-mint on read. Don't sit on a spinner — fail loud so the user
+ // gets a retry instead of an indefinite wait. (A fresh, different-token challenge
+ // would have re-triggered this effect via the token dependency.)
+ failWith(ERROR_CODES.PROTECT_CHECK_TIMED_OUT, 'Protect verification expired');
+ }
+ } catch (err: any) {
+ if (!cancelled) {
+ handleError(err, [], card.setError);
+ }
+ } finally {
+ if (!cancelled) {
+ isRunningRef.current = false;
+ setIsRunning(false);
+ }
+ }
+ })();
+ return cleanup;
+ }
+
+ const container = containerRef.current;
+ if (!container) {
+ return;
+ }
+
+ isRunningRef.current = true;
+ setIsRunning(true);
+
+ const runChallenge = async () => {
+ let timeoutId: ReturnType | undefined;
+ try {
+ const proofToken = await Promise.race([
+ executeProtectCheck(protectCheck, container, { signal: abortController.signal }),
+ new Promise((_, reject) => {
+ timeoutId = setTimeout(() => {
+ // Stop the (possibly hung) SDK and surface a retryable timeout error.
+ abortController.abort();
+ reject(
+ new ClerkRuntimeError('Protect verification timed out', {
+ code: ERROR_CODES.PROTECT_CHECK_TIMED_OUT,
+ }),
+ );
+ }, PROTECT_CHECK_SCRIPT_TIMEOUT_MS);
+ }),
+ ]);
+ if (cancelled) {
+ return;
+ }
+
+ let updatedResource: TResource;
+ try {
+ updatedResource = await submitProtectCheck({ proofToken });
+ } catch (err) {
+ if (cancelled) {
+ return;
+ }
+ // `protect_check_already_resolved` is retry-safe: the server's state has already moved
+ // past this gate. Reload to clear the stale local protectCheck, then continue routing on
+ // the refreshed live resource.
+ if (isClerkAPIResponseError(err) && err.errors?.[0]?.code === ERROR_CODES.PROTECT_CHECK_ALREADY_RESOLVED) {
+ await reload();
+ if (cancelled) {
+ return;
+ }
+ await onResolved(getResource(), isCancelled);
+ return;
+ }
+ throw err;
+ }
+ if (cancelled) {
+ return;
+ }
+ await onResolved(updatedResource, isCancelled);
+ } catch (err: any) {
+ if (cancelled) {
+ return;
+ }
+ handleError(err, [], card.setError);
+ } finally {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ if (!cancelled) {
+ isRunningRef.current = false;
+ setIsRunning(false);
+ }
+ }
+ };
+
+ void runChallenge();
+ return cleanup;
+ // Keyed on the challenge token (a primitive) rather than the protectCheck object: an unrelated
+ // resource refresh keeps the same token and must NOT restart the challenge, while a genuine
+ // chained challenge carries a new token and re-runs. `retryNonce` re-runs on manual retry.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [token, retryNonce]);
+
+ const retry = React.useCallback(() => {
+ card.setError('');
+ reloadCountRef.current = 0;
+ isRunningRef.current = false;
+ setRetryNonce(n => n + 1);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return { containerRef, isRunning, hasError: !!card.error, retry };
+}
diff --git a/packages/ui/src/test/fixture-helpers.ts b/packages/ui/src/test/fixture-helpers.ts
index 4d016be6f31..2f33a1b36e8 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,
};
};
diff --git a/references/mosaic-architecture.md b/references/mosaic-architecture.md
index d7983c693d3..2d098aa1750 100644
--- a/references/mosaic-architecture.md
+++ b/references/mosaic-architecture.md
@@ -45,7 +45,8 @@ theme.mix('primary', 'primaryForeground', 50); // "color-mix(in oklab,
// text(key) — typography scale with fontSize + lineHeight
theme.text('sm'); // { fontSize: '0.875rem', lineHeight: '...' }
```
-```
+
+````
## Theme delivery
@@ -57,7 +58,7 @@ Single provider that handles cascade and theme delivery:
import { MosaicProvider } from '../mosaic/MosaicProvider';
{children};
-```
+````
### useMosaicTheme