diff --git a/.changeset/managed-protect-check-gate.md b/.changeset/managed-protect-check-gate.md
new file mode 100644
index 00000000000..e18be7533b8
--- /dev/null
+++ b/.changeset/managed-protect-check-gate.md
@@ -0,0 +1,5 @@
+---
+'@clerk/clerk-js': minor
+---
+
+clerk-js now resolves Clerk Protect challenges (`protect_check`) automatically in apps built on custom flows: when a sign-in or sign-up call is gated, the challenge runs in a Clerk-managed modal (or inline, when a `
` placement element is present) and the original call resolves with the post-challenge state. Prebuilt components keep their existing inline challenge experience. No action is required from applications; the behavior activates only for instances where Clerk Protect challenges are enabled.
diff --git a/.changeset/protect-check-marker-constant.md b/.changeset/protect-check-marker-constant.md
new file mode 100644
index 00000000000..9e0d914c6c1
--- /dev/null
+++ b/.changeset/protect-check-marker-constant.md
@@ -0,0 +1,5 @@
+---
+'@clerk/shared': patch
+---
+
+Add the internal `PROTECT_CHECK_ELEMENT_ID` constant for the Protect challenge placement marker. Internal change; no public API changes.
diff --git a/packages/clerk-js/src/core/__tests__/fraudProtection.protectCheck.test.ts b/packages/clerk-js/src/core/__tests__/fraudProtection.protectCheck.test.ts
new file mode 100644
index 00000000000..0ea8f65bf18
--- /dev/null
+++ b/packages/clerk-js/src/core/__tests__/fraudProtection.protectCheck.test.ts
@@ -0,0 +1,69 @@
+import { PROTECT_CHECK_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants';
+import type { ProtectCheckJSON } from '@clerk/shared/types';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { FapiResponseJSON } from '../fapiClient';
+import { FraudProtection } from '../fraudProtection';
+import type { Clerk } from '../resources/internal';
+
+vi.mock('@clerk/shared/internal/clerk-js/protectCheckLifecycle', async importOriginal => ({
+ ...(await importOriginal()),
+ executeProtectCheckWithTimeout: vi.fn(),
+}));
+
+import { executeProtectCheckWithTimeout } from '@clerk/shared/internal/clerk-js/protectCheckLifecycle';
+
+const mockExecute = vi.mocked(executeProtectCheckWithTimeout);
+
+const gatedPayload = (): FapiResponseJSON =>
+ ({
+ response: {
+ object: 'sign_in',
+ id: 'si_wired',
+ status: 'needs_protect_check',
+ protect_check: {
+ status: 'pending',
+ token: 'challenge-token',
+ sdk_url: 'https://protect.example.com/sdk.js',
+ } satisfies ProtectCheckJSON,
+ },
+ }) as FapiResponseJSON;
+
+afterEach(() => {
+ document.body.innerHTML = '';
+ mockExecute.mockReset();
+});
+
+describe('FraudProtection × ProtectCheckGate wiring', () => {
+ it('resolves a gated payload through the gate when a raw fetch is provided', async () => {
+ // Inline marker host: keeps the wiring test free of modal plumbing.
+ const marker = document.createElement('div');
+ marker.id = PROTECT_CHECK_ELEMENT_ID;
+ document.body.appendChild(marker);
+
+ mockExecute.mockResolvedValue('proof-wired');
+ const resolved = { response: { object: 'sign_in', id: 'si_wired', status: 'complete', protect_check: null } };
+ const rawFetch = vi.fn(() => Promise.resolve(resolved as FapiResponseJSON));
+
+ const result = await FraudProtection.getInstance().execute(
+ {} as unknown as Clerk,
+ () => Promise.resolve(gatedPayload()),
+ rawFetch,
+ );
+
+ expect(result).toBe(resolved);
+ expect(rawFetch).toHaveBeenCalledWith({
+ method: 'PATCH',
+ path: '/client/sign_ins/si_wired/protect_check',
+ body: { proof_token: 'proof-wired' },
+ });
+ });
+
+ it('returns payloads untouched when no raw fetch is provided (non-resource callers)', async () => {
+ const payload = gatedPayload();
+ await expect(
+ FraudProtection.getInstance().execute({} as unknown as Clerk, () => Promise.resolve(payload)),
+ ).resolves.toBe(payload);
+ expect(mockExecute).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/clerk-js/src/core/__tests__/protectCheckGate.test.ts b/packages/clerk-js/src/core/__tests__/protectCheckGate.test.ts
new file mode 100644
index 00000000000..2a17689995c
--- /dev/null
+++ b/packages/clerk-js/src/core/__tests__/protectCheckGate.test.ts
@@ -0,0 +1,437 @@
+import { ClerkAPIResponseError } from '@clerk/shared/error';
+import type { ProtectCheckJSON } from '@clerk/shared/types';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { FapiResponseJSON } from '../fapiClient';
+import type { RawResourceFetch } from '../protectCheckGate';
+import {
+ findPendingProtectCheck,
+ PROTECT_CHECK_MODAL_CONTAINER_ID,
+ PROTECT_CHECK_MODAL_WRAPPER_ID,
+ ProtectCheckGate,
+} from '../protectCheckGate';
+import type { Clerk } from '../resources/internal';
+
+vi.mock('@clerk/shared/internal/clerk-js/protectCheckLifecycle', async importOriginal => ({
+ ...(await importOriginal()),
+ executeProtectCheckWithTimeout: vi.fn(),
+}));
+
+import { executeProtectCheckWithTimeout } from '@clerk/shared/internal/clerk-js/protectCheckLifecycle';
+
+const mockExecute = vi.mocked(executeProtectCheckWithTimeout);
+
+const checkJSON = (overrides: Partial = {}): ProtectCheckJSON => ({
+ status: 'pending',
+ token: 'challenge-token',
+ sdk_url: 'https://protect.example.com/sdk.js',
+ ...overrides,
+});
+
+const signInPayload = (protect_check: ProtectCheckJSON | null, id = 'si_1'): FapiResponseJSON =>
+ ({
+ response: {
+ object: 'sign_in',
+ id,
+ status: protect_check ? 'needs_protect_check' : 'needs_first_factor',
+ protect_check,
+ },
+ }) as FapiResponseJSON;
+
+const signUpPayload = (protect_check: ProtectCheckJSON | null, id = 'su_1'): FapiResponseJSON =>
+ ({
+ response: { object: 'sign_up', id, status: 'missing_requirements', protect_check },
+ }) as FapiResponseJSON;
+
+const alreadyResolvedError = () =>
+ new ClerkAPIResponseError('Already resolved', {
+ data: [{ code: 'protect_check_already_resolved', message: 'Already resolved', long_message: '' }],
+ status: 400,
+ clerkTraceId: 'trace_123',
+ });
+
+/**
+ * Fake modal host: `open` mounts the wrapper + container ids the gate queries, `close` removes
+ * them — the contract the ui package's ProtectCheckModal will fulfil.
+ */
+const makeClerk = () => {
+ const open = vi.fn(() => {
+ const wrapper = document.createElement('div');
+ wrapper.id = PROTECT_CHECK_MODAL_WRAPPER_ID;
+ wrapper.style.visibility = 'hidden';
+ const container = document.createElement('div');
+ container.id = PROTECT_CHECK_MODAL_CONTAINER_ID;
+ wrapper.appendChild(container);
+ document.body.appendChild(wrapper);
+ return Promise.resolve();
+ });
+ const close = vi.fn(() => {
+ document.getElementById(PROTECT_CHECK_MODAL_WRAPPER_ID)?.remove();
+ return Promise.resolve();
+ });
+ return {
+ clerk: {
+ __internal_openProtectCheckModal: open,
+ __internal_closeProtectCheckModal: close,
+ } as unknown as Clerk,
+ open,
+ close,
+ };
+};
+
+const makeRawFetch = (handlers: {
+ onPatch?: (path: string, body: unknown) => FapiResponseJSON | Promise>;
+ onGet?: (path: string) => FapiResponseJSON | Promise>;
+}) =>
+ vi.fn(async (init: { method: 'GET' | 'PATCH'; path: string; body?: unknown }) => {
+ if (init.method === 'PATCH') {
+ if (!handlers.onPatch) {
+ throw new Error(`unexpected PATCH ${init.path}`);
+ }
+ return handlers.onPatch(init.path, init.body);
+ }
+ if (!handlers.onGet) {
+ throw new Error(`unexpected GET ${init.path}`);
+ }
+ return handlers.onGet(init.path);
+ }) as unknown as RawResourceFetch & ReturnType;
+
+beforeEach(() => {
+ mockExecute.mockReset();
+});
+
+afterEach(() => {
+ document.body.innerHTML = '';
+});
+
+describe('findPendingProtectCheck', () => {
+ it('detects a pending check on a direct sign-in response', () => {
+ expect(findPendingProtectCheck(signInPayload(checkJSON()))).toEqual({
+ flow: 'signIn',
+ id: 'si_1',
+ check: {
+ status: 'pending',
+ token: 'challenge-token',
+ sdkUrl: 'https://protect.example.com/sdk.js',
+ expiresAt: undefined,
+ uiHints: undefined,
+ },
+ });
+ });
+
+ it('detects a pending check on a direct sign-up response', () => {
+ expect(findPendingProtectCheck(signUpPayload(checkJSON()))?.flow).toBe('signUp');
+ });
+
+ it.each([
+ ['null payload', null],
+ ['non-auth response', { response: { object: 'client', id: 'c_1' } } as FapiResponseJSON],
+ ['no protect_check', signInPayload(null)],
+ ['completed protect_check', signInPayload(checkJSON({ status: 'completed' as ProtectCheckJSON['status'] }))],
+ [
+ 'client-nested check only (belongs to another call)',
+ {
+ response: {
+ object: 'client',
+ id: 'c_1',
+ sign_in: { object: 'sign_in', id: 'si_1', protect_check: checkJSON() },
+ },
+ } as unknown as FapiResponseJSON,
+ ],
+ ])('ignores %s', (_label, payload) => {
+ expect(findPendingProtectCheck(payload)).toBeNull();
+ });
+});
+
+describe('ProtectCheckGate.process', () => {
+ it('passes non-gated payloads through untouched', async () => {
+ const gate = new ProtectCheckGate();
+ const { clerk, open } = makeClerk();
+ const payload = signInPayload(null);
+ const rawFetch = makeRawFetch({});
+
+ await expect(gate.process(clerk, payload, () => Promise.resolve(payload), rawFetch)).resolves.toBe(payload);
+ expect(open).not.toHaveBeenCalled();
+ expect(rawFetch).not.toHaveBeenCalled();
+ });
+
+ it('passes gated payloads through untouched while a host is registered for the flow', async () => {
+ const gate = new ProtectCheckGate();
+ const { clerk, open } = makeClerk();
+ const payload = signInPayload(checkJSON());
+ const dispose = gate.registerHost('signIn');
+
+ await expect(gate.process(clerk, payload, () => Promise.resolve(payload), makeRawFetch({}))).resolves.toBe(payload);
+ expect(open).not.toHaveBeenCalled();
+
+ dispose();
+ dispose(); // double-dispose must not underflow
+ expect(gate.hasRegisteredHost('signIn')).toBe(false);
+ });
+
+ it('a registered sign-up host does not suppress sign-in handling', async () => {
+ const gate = new ProtectCheckGate();
+ const { clerk, open } = makeClerk();
+ gate.registerHost('signUp');
+ mockExecute.mockResolvedValue('proof-1');
+ const resolved = signInPayload(null);
+ const rawFetch = makeRawFetch({ onPatch: () => resolved });
+
+ await expect(
+ gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), rawFetch),
+ ).resolves.toBe(resolved);
+ expect(open).toHaveBeenCalledTimes(1);
+ });
+
+ it('resolves a gated sign-in through the managed modal: execute → PATCH → post-challenge payload', async () => {
+ const gate = new ProtectCheckGate();
+ const { clerk, open, close } = makeClerk();
+ mockExecute.mockResolvedValue('proof-1');
+ const resolved = signInPayload(null);
+ const rawFetch = makeRawFetch({ onPatch: () => resolved });
+
+ const result = await gate.process(
+ clerk,
+ signInPayload(checkJSON()),
+ () => Promise.resolve(signInPayload(null)),
+ rawFetch,
+ );
+
+ expect(result).toBe(resolved);
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(mockExecute).toHaveBeenCalledWith(
+ expect.objectContaining({ token: 'challenge-token', sdkUrl: 'https://protect.example.com/sdk.js' }),
+ expect.any(HTMLElement),
+ expect.objectContaining({ setWidgetVisible: expect.any(Function) }),
+ );
+ expect((mockExecute.mock.calls[0][1] as HTMLElement).id).toBe(PROTECT_CHECK_MODAL_CONTAINER_ID);
+ expect(rawFetch).toHaveBeenCalledWith({
+ method: 'PATCH',
+ path: '/client/sign_ins/si_1/protect_check',
+ body: { proof_token: 'proof-1' },
+ });
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it('uses the sign-up endpoints for gated sign-ups', async () => {
+ const gate = new ProtectCheckGate();
+ const { clerk } = makeClerk();
+ mockExecute.mockResolvedValue('proof-su');
+ const resolved = signUpPayload(null);
+ const rawFetch = makeRawFetch({ onPatch: () => resolved });
+
+ await gate.process(clerk, signUpPayload(checkJSON()), () => Promise.resolve(signUpPayload(null)), rawFetch);
+
+ expect(rawFetch).toHaveBeenCalledWith({
+ method: 'PATCH',
+ path: '/client/sign_ups/su_1/protect_check',
+ body: { proof_token: 'proof-su' },
+ });
+ });
+
+ it('runs inline into the clerk-protect-check placement marker instead of the modal', async () => {
+ const gate = new ProtectCheckGate();
+ const { clerk, open } = makeClerk();
+ const marker = document.createElement('div');
+ marker.id = 'clerk-protect-check';
+ document.body.appendChild(marker);
+ mockExecute.mockResolvedValue('proof-1');
+ const resolved = signInPayload(null);
+ const rawFetch = makeRawFetch({ onPatch: () => resolved });
+
+ await gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), rawFetch);
+
+ expect(open).not.toHaveBeenCalled();
+ expect(mockExecute.mock.calls[0][1]).toBe(marker);
+ });
+
+ it('loops chained challenges inside one host session', async () => {
+ const gate = new ProtectCheckGate();
+ const { clerk, open, close } = makeClerk();
+ mockExecute.mockResolvedValueOnce('proof-1').mockResolvedValueOnce('proof-2');
+ const chained = signInPayload(checkJSON({ token: 'challenge-token-2' }));
+ const resolved = signInPayload(null);
+ let patchCount = 0;
+ const rawFetch = makeRawFetch({ onPatch: () => (++patchCount === 1 ? chained : resolved) });
+
+ const result = await gate.process(
+ clerk,
+ signInPayload(checkJSON()),
+ () => Promise.resolve(signInPayload(null)),
+ rawFetch,
+ );
+
+ expect(result).toBe(resolved);
+ expect(mockExecute).toHaveBeenCalledTimes(2);
+ expect(mockExecute.mock.calls[1][0]).toEqual(expect.objectContaining({ token: 'challenge-token-2' }));
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it('gives up on a never-ending challenge chain and closes the host', async () => {
+ const gate = new ProtectCheckGate();
+ const { clerk, close } = makeClerk();
+ let n = 0;
+ mockExecute.mockImplementation(() => Promise.resolve(`proof-${n}`));
+ const rawFetch = makeRawFetch({ onPatch: () => signInPayload(checkJSON({ token: `challenge-token-${++n}` })) });
+
+ await expect(
+ gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), rawFetch),
+ ).rejects.toMatchObject({ code: 'protect_check_execution_failed' });
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it('treats protect_check_already_resolved as soft success: reloads and returns the live payload', async () => {
+ const gate = new ProtectCheckGate();
+ const { clerk } = makeClerk();
+ mockExecute.mockResolvedValue('proof-1');
+ const live = signInPayload(null);
+ const rawFetch = makeRawFetch({
+ onPatch: () => {
+ throw alreadyResolvedError();
+ },
+ onGet: () => live,
+ });
+
+ const result = await gate.process(
+ clerk,
+ signInPayload(checkJSON()),
+ () => Promise.resolve(signInPayload(null)),
+ rawFetch,
+ );
+
+ expect(result).toBe(live);
+ expect(rawFetch).toHaveBeenCalledWith(
+ { method: 'GET', path: '/client/sign_ins/si_1' },
+ { forceUpdateClient: true },
+ );
+ });
+
+ it('reloads an expired challenge before running and uses the re-minted check', async () => {
+ const gate = new ProtectCheckGate();
+ const { clerk } = makeClerk();
+ mockExecute.mockResolvedValue('proof-fresh');
+ const reMinted = signInPayload(checkJSON({ token: 'challenge-token-fresh', expires_at: Date.now() + 60_000 }));
+ const resolved = signInPayload(null);
+ const rawFetch = makeRawFetch({ onGet: () => reMinted, onPatch: () => resolved });
+
+ const result = await gate.process(
+ clerk,
+ signInPayload(checkJSON({ expires_at: Date.now() - 1_000 })),
+ () => Promise.resolve(signInPayload(null)),
+ rawFetch,
+ );
+
+ expect(result).toBe(resolved);
+ expect(mockExecute).toHaveBeenCalledTimes(1);
+ expect(mockExecute.mock.calls[0][0]).toEqual(expect.objectContaining({ token: 'challenge-token-fresh' }));
+ });
+
+ it('fails with protect_check_timed_out when the server keeps returning an expired challenge', async () => {
+ const gate = new ProtectCheckGate();
+ const { clerk, close } = makeClerk();
+ const rawFetch = makeRawFetch({ onGet: () => signInPayload(checkJSON({ expires_at: Date.now() - 1_000 })) });
+
+ await expect(
+ gate.process(
+ clerk,
+ signInPayload(checkJSON({ expires_at: Date.now() - 1_000 })),
+ () => Promise.resolve(signInPayload(null)),
+ rawFetch,
+ ),
+ ).rejects.toMatchObject({ code: 'protect_check_timed_out' });
+ expect(mockExecute).not.toHaveBeenCalled();
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it('propagates challenge failures and closes the host', async () => {
+ const gate = new ProtectCheckGate();
+ const { clerk, close } = makeClerk();
+ mockExecute.mockRejectedValue(
+ Object.assign(new Error('load failed'), { code: 'protect_check_script_load_failed' }),
+ );
+
+ await expect(
+ gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), makeRawFetch({})),
+ ).rejects.toMatchObject({ code: 'protect_check_script_load_failed' });
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it('single-flights concurrent gated calls: second waits, then replays instead of opening a second host', async () => {
+ const gate = new ProtectCheckGate();
+ const { clerk, open } = makeClerk();
+ let resolveProof: (token: string) => void = () => undefined;
+ mockExecute.mockImplementationOnce(
+ () =>
+ new Promise(resolve => {
+ resolveProof = resolve;
+ }),
+ );
+ const resolved = signInPayload(null);
+ const rawFetch = makeRawFetch({ onPatch: () => resolved });
+
+ const first = gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), rawFetch);
+ await vi.waitFor(() => expect(open).toHaveBeenCalledTimes(1));
+
+ const replaidPayload = signInPayload(null, 'si_2');
+ const replay = vi.fn(() => Promise.resolve(replaidPayload));
+ const second = gate.process(clerk, signInPayload(checkJSON(), 'si_2'), replay, rawFetch);
+
+ resolveProof('proof-1');
+ await expect(first).resolves.toBe(resolved);
+ await expect(second).resolves.toBe(replaidPayload);
+ expect(replay).toHaveBeenCalledTimes(1);
+ expect(open).toHaveBeenCalledTimes(1);
+ });
+
+ it('flips the modal wrapper visible when the script announces its widget', async () => {
+ const gate = new ProtectCheckGate();
+ const { clerk } = makeClerk();
+ let capturedSetWidgetVisible: ((visible: boolean) => Promise) | undefined;
+ mockExecute.mockImplementation(async (_check, _container, opts) => {
+ capturedSetWidgetVisible = opts?.setWidgetVisible;
+ await opts?.setWidgetVisible?.(true);
+ return 'proof-1';
+ });
+ const resolved = signInPayload(null);
+ const rawFetch = makeRawFetch({
+ onPatch: () => {
+ // Wrapper must already be visible by the time the proof is submitted.
+ expect(document.getElementById(PROTECT_CHECK_MODAL_WRAPPER_ID)?.style.visibility).toBe('visible');
+ return resolved;
+ },
+ });
+
+ await gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), rawFetch);
+ expect(capturedSetWidgetVisible).toBeDefined();
+ });
+
+ it('reveals a still-running modal after the delay so long solves are not an invisible frozen page', async () => {
+ vi.useFakeTimers();
+ try {
+ const gate = new ProtectCheckGate();
+ const { clerk } = makeClerk();
+ let resolveProof: (token: string) => void = () => undefined;
+ mockExecute.mockImplementationOnce(
+ () =>
+ new Promise(resolve => {
+ resolveProof = resolve;
+ }),
+ );
+ const resolved = signInPayload(null);
+ const rawFetch = makeRawFetch({ onPatch: () => resolved });
+
+ const run = gate.process(clerk, signInPayload(checkJSON()), () => Promise.resolve(signInPayload(null)), rawFetch);
+ await vi.waitFor(() => expect(mockExecute).toHaveBeenCalled());
+ expect(document.getElementById(PROTECT_CHECK_MODAL_WRAPPER_ID)?.style.visibility).toBe('hidden');
+
+ await vi.advanceTimersByTimeAsync(500);
+ expect(document.getElementById(PROTECT_CHECK_MODAL_WRAPPER_ID)?.style.visibility).toBe('visible');
+
+ resolveProof('proof-1');
+ await expect(run).resolves.toBe(resolved);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index 0622261d9af..4abaf229116 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -192,6 +192,7 @@ import { createCheckoutInstance } from './modules/checkout/instance';
import { OAuthApplication } from './modules/oauthApplication';
import { Protect } from './protect';
import { protectAssertionParams } from './protectAssertion';
+import { ProtectCheckGate } from './protectCheckGate';
import { BaseResource, Client, Environment, Organization, Waitlist } from './resources/internal';
import { State } from './state';
@@ -962,6 +963,24 @@ export class Clerk implements ClerkInterface {
return this.#clerkUI.then(ui => ui.ensureMounted()).then(controls => controls.closeModal('blankCaptcha'));
};
+ public __internal_openProtectCheckModal = (): Promise => {
+ this.assertComponentsReady(this.#clerkUI);
+ return this.#clerkUI.then(ui => ui.ensureMounted()).then(controls => controls.openModal('protectCheck', {}));
+ };
+
+ public __internal_closeProtectCheckModal = (): Promise => {
+ this.assertComponentsReady(this.#clerkUI);
+ return this.#clerkUI.then(ui => ui.ensureMounted()).then(controls => controls.closeModal('protectCheck'));
+ };
+
+ /**
+ * Lets a mounted surface that renders Protect challenges itself (prebuilt components, the
+ * inline marker component) suspend managed challenge handling for its flow. Returns a disposer.
+ */
+ public __internal_registerProtectCheckHost = (flow: 'signIn' | 'signUp'): (() => void) => {
+ return ProtectCheckGate.getInstance().registerHost(flow);
+ };
+
public __internal_loadStripeJs = async () => {
if (__BUILD_DISABLE_RHC__) {
clerkUnsupportedEnvironmentWarning('Stripe');
diff --git a/packages/clerk-js/src/core/fraudProtection.ts b/packages/clerk-js/src/core/fraudProtection.ts
index 8dcac4aebed..73688ceefda 100644
--- a/packages/clerk-js/src/core/fraudProtection.ts
+++ b/packages/clerk-js/src/core/fraudProtection.ts
@@ -1,6 +1,8 @@
import { ClerkRuntimeError, isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error';
import { CaptchaChallenge } from '../utils/captcha/CaptchaChallenge';
+import type { RawResourceFetch } from './protectCheckGate';
+import { ProtectCheckGate } from './protectCheckGate';
import type { Clerk } from './resources/internal';
import { Client } from './resources/internal';
@@ -25,7 +27,22 @@ export class FraudProtection {
) {}
// TODO @userland-errors:
- public async execute Promise, R = Awaited>>(clerk: Clerk, cb: T): Promise {
+ public async execute Promise, R = Awaited>>(
+ clerk: Clerk,
+ cb: T,
+ rawFetch?: RawResourceFetch,
+ ): Promise {
+ // Managed Protect challenges ride successful payloads (HTTP 200 + pending `protect_check`),
+ // unlike the legacy captcha's error path, so every path that returns a result — including
+ // the post-captcha replays below — funnels through this gate check.
+ const run = async (): Promise => {
+ const result = await cb();
+ if (!rawFetch) {
+ return result;
+ }
+ return (await ProtectCheckGate.getInstance().process(clerk, result, cb, rawFetch)) as R;
+ };
+
// TODO @userland-errors:
if (this.captchaAttemptsExceeded()) {
throw new ClerkRuntimeError(
@@ -39,7 +56,7 @@ export class FraudProtection {
await this.inflightException;
}
- return await cb();
+ return await run();
} catch (e) {
if (!isClerkAPIResponseError(e)) {
throw e;
@@ -60,7 +77,7 @@ export class FraudProtection {
await this.inflightException;
// If this is resolved, it means the request finally resolved with 200
// so we can replay the original request
- return await cb();
+ return await run();
}
// Otherwise, create a new placeholder promise to prevent other exceptions from being handled
@@ -82,7 +99,7 @@ export class FraudProtection {
this.inflightException = null;
}
- return await cb();
+ return await run();
}
}
diff --git a/packages/clerk-js/src/core/protectCheckGate.ts b/packages/clerk-js/src/core/protectCheckGate.ts
new file mode 100644
index 00000000000..bf8f5c65202
--- /dev/null
+++ b/packages/clerk-js/src/core/protectCheckGate.ts
@@ -0,0 +1,307 @@
+import { waitForElement } from '@clerk/shared/dom';
+import { ClerkRuntimeError } from '@clerk/shared/error';
+import { ERROR_CODES, PROTECT_CHECK_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants';
+import type { ProtectCheckJSON, ProtectCheckResource } from '@clerk/shared/types';
+
+import type { FapiResponseJSON } from './fapiClient';
+import type { Clerk } from './resources/internal';
+
+export const PROTECT_CHECK_MODAL_WRAPPER_ID = 'cl-modal-protect-check-wrapper';
+export const PROTECT_CHECK_MODAL_CONTAINER_ID = 'cl-modal-protect-check-container';
+
+/**
+ * The managed modal opens invisible so a challenge that resolves without interaction never
+ * flashes UI (same posture as the captcha modal). Unlike captcha, a challenge can legitimately
+ * run for a while (proof-of-transfer), so a still-running check reveals the modal after this
+ * delay instead of leaving the page frozen with nothing visible.
+ */
+const MODAL_REVEAL_DELAY_MS = 500;
+
+/**
+ * Chained challenges are an SDK-side loop (the PATCH response may carry a fresh check). A
+ * server bug that chains forever must not trap the user in the modal.
+ */
+const MAX_CHAINED_CHALLENGES = 5;
+
+/**
+ * Rounds of await-another-session-then-replay per gated call. Replays re-enter the gate, so a
+ * pathological server that re-gates every replay must not loop forever; past the cap the gated
+ * payload is returned as-is, surfacing the documented `needs_protect_check` state instead of an
+ * opaque failure.
+ */
+const MAX_GATED_ROUNDS = 3;
+
+type ProtectFlow = 'signIn' | 'signUp';
+
+/**
+ * Raw resource fetch, provided by `BaseResource._fetch` so the gate's own PATCH/GET calls get
+ * the exact semantics of any resource call (client piggyback updates, ClerkAPIResponseError on
+ * 4xx) without re-entering FraudProtection.
+ */
+export type RawResourceFetch = (
+ requestInit: { method: 'GET' | 'PATCH'; path: string; body?: unknown },
+ opts?: { forceUpdateClient?: boolean },
+) => Promise | null>;
+
+interface GatedInfo {
+ flow: ProtectFlow;
+ id: string;
+ check: ProtectCheckResource;
+}
+
+interface ChallengeHost {
+ container: HTMLDivElement;
+ setWidgetVisible?: (visible: boolean) => Promise;
+ close: () => void;
+}
+
+type MaybeGatedResponse = {
+ object?: string;
+ id?: string;
+ protect_check?: ProtectCheckJSON | null;
+};
+
+function toProtectCheckResource(json: ProtectCheckJSON): ProtectCheckResource {
+ return {
+ status: json.status,
+ token: json.token,
+ sdkUrl: json.sdk_url,
+ expiresAt: json.expires_at,
+ uiHints: json.ui_hints,
+ };
+}
+
+/**
+ * A payload gates the calling request when its direct response is a sign-in/sign-up carrying a
+ * pending `protect_check`. Only the direct response is inspected: the gated call's own response
+ * is the authoritative signal, and reacting to the piggybacked `client` mirror would double-handle
+ * gates that belong to a different in-flight call.
+ */
+export function findPendingProtectCheck(payload: FapiResponseJSON | null): GatedInfo | null {
+ const response = payload?.response as MaybeGatedResponse | null | undefined;
+ if (!response || typeof response !== 'object') {
+ return null;
+ }
+ if (response.object !== 'sign_in' && response.object !== 'sign_up') {
+ return null;
+ }
+ if (!response.id || response.protect_check?.status !== 'pending') {
+ return null;
+ }
+ return {
+ flow: response.object === 'sign_in' ? 'signIn' : 'signUp',
+ id: response.id,
+ check: toProtectCheckResource(response.protect_check),
+ };
+}
+
+/**
+ * Resolves Protect challenges (`protect_check`) automatically so custom-flow apps never see the
+ * gate: when a resource call comes back gated, the challenge runs in a Clerk-owned host — the
+ * `clerk-protect-check` placement marker when the page provides one, a managed modal otherwise —
+ * the proof is submitted, and the post-challenge payload is returned as the original call's
+ * result. Prebuilt components (and any other surface that renders challenges itself) opt out by
+ * registering a host for their flow, in which case gated payloads pass through untouched.
+ *
+ * Mirrors `FraudProtection`'s posture for the legacy captcha: one challenge session at a time
+ * (concurrent gated calls wait, then replay), and the caller's promise is held for the duration.
+ */
+export class ProtectCheckGate {
+ private static instance: ProtectCheckGate;
+
+ private hostCounts: Record = { signIn: 0, signUp: 0 };
+ private inflightSession: Promise | null = null;
+
+ public static getInstance(): ProtectCheckGate {
+ if (!ProtectCheckGate.instance) {
+ ProtectCheckGate.instance = new ProtectCheckGate();
+ }
+ return ProtectCheckGate.instance;
+ }
+
+ /**
+ * Declares that a mounted surface (prebuilt component, inline marker component) renders
+ * challenges for the given flow itself; managed handling stands down while any registration
+ * is live. Returns a disposer.
+ */
+ public registerHost(flow: ProtectFlow): () => void {
+ this.hostCounts[flow] += 1;
+ let disposed = false;
+ return () => {
+ if (!disposed) {
+ disposed = true;
+ this.hostCounts[flow] -= 1;
+ }
+ };
+ }
+
+ public hasRegisteredHost(flow: ProtectFlow): boolean {
+ return this.hostCounts[flow] > 0;
+ }
+
+ public async process(clerk: Clerk, payload: T, replay: () => Promise, rawFetch: RawResourceFetch): Promise {
+ let current = payload;
+ let rounds = 0;
+
+ for (;;) {
+ const gated = findPendingProtectCheck(current as FapiResponseJSON | null);
+ if (!gated || this.hasRegisteredHost(gated.flow)) {
+ return current;
+ }
+ if (rounds >= MAX_GATED_ROUNDS) {
+ return current;
+ }
+ rounds += 1;
+
+ if (this.inflightSession) {
+ // Another gated call owns the challenge UI. Wait it out (its failure is its caller's to
+ // surface), then replay: the stored proof on the attempt lets the replay pass without a
+ // second challenge.
+ await this.inflightSession.catch(() => undefined);
+ current = await replay();
+ continue;
+ }
+
+ const session = this.resolveGated(clerk, gated, rawFetch);
+ this.inflightSession = session.catch(() => undefined);
+ try {
+ current = (await session) as T;
+ } finally {
+ this.inflightSession = null;
+ }
+ }
+ }
+
+ private async resolveGated(
+ clerk: Clerk,
+ gated: GatedInfo,
+ rawFetch: RawResourceFetch,
+ ): Promise | null> {
+ // Fail closed where the challenge cannot run: the gate requires a remote `import(sdk_url)`
+ // that no-RHC builds must not perform, and a DOM to host the widget. The guard lives here
+ // (not in the shared lifecycle module) because @clerk/shared compiles with the flag
+ // hard-coded `false`.
+ if (__BUILD_DISABLE_RHC__ || typeof document === 'undefined') {
+ throw new ClerkRuntimeError('Protect verification is not supported in this environment', {
+ code: ERROR_CODES.PROTECT_CHECK_UNSUPPORTED_ENVIRONMENT,
+ });
+ }
+
+ const lifecycle = await import('@clerk/shared/internal/clerk-js/protectCheckLifecycle');
+ const host = await this.acquireHost(clerk);
+
+ const basePath = gated.flow === 'signIn' ? '/client/sign_ins' : '/client/sign_ups';
+ const reload = () => rawFetch({ method: 'GET', path: `${basePath}/${gated.id}` }, { forceUpdateClient: true });
+ const submit = (proofToken: string) =>
+ rawFetch({ method: 'PATCH', path: `${basePath}/${gated.id}/protect_check`, body: { proof_token: proofToken } });
+
+ try {
+ let latest: FapiResponseJSON | null = null;
+ let check: ProtectCheckResource | null = gated.check;
+ let expiredReloads = 0;
+ let challengesRun = 0;
+
+ while (check) {
+ if (lifecycle.isProtectCheckExpired(check)) {
+ if (expiredReloads >= lifecycle.MAX_EXPIRED_RELOADS) {
+ throw new ClerkRuntimeError('Protect verification expired', {
+ code: ERROR_CODES.PROTECT_CHECK_TIMED_OUT,
+ });
+ }
+ expiredReloads += 1;
+ latest = await reload();
+ check = findPendingProtectCheck(latest)?.check ?? null;
+ continue;
+ }
+
+ if (challengesRun >= MAX_CHAINED_CHALLENGES) {
+ throw new ClerkRuntimeError('Protect check chained challenge limit exceeded', {
+ code: 'protect_check_execution_failed',
+ });
+ }
+ challengesRun += 1;
+
+ const proofToken = await lifecycle.executeProtectCheckWithTimeout(check, host.container, {
+ setWidgetVisible: host.setWidgetVisible,
+ });
+
+ const result = await lifecycle.submitProtectCheckProof | null>({
+ proofToken,
+ submitProtectCheck: ({ proofToken: token }) => submit(token),
+ reload: async () => {
+ latest = await reload();
+ },
+ getResource: () => latest,
+ });
+ if (result.status === 'cancelled') {
+ break;
+ }
+ latest = result.resource;
+ check = findPendingProtectCheck(latest)?.check ?? null;
+ }
+
+ return latest;
+ } finally {
+ host.close();
+ }
+ }
+
+ private async acquireHost(clerk: Clerk): Promise {
+ const marker = document.getElementById(PROTECT_CHECK_ELEMENT_ID);
+ if (marker) {
+ return { container: marker as HTMLDivElement, close: () => undefined };
+ }
+
+ try {
+ await clerk.__internal_openProtectCheckModal();
+ } catch {
+ // Mirrors the captcha modal's components-not-ready race, but Protect cannot fail open —
+ // the server enforces the gate — so surface a runtime error instead of skipping.
+ throw new ClerkRuntimeError('Protect check UI failed to open', {
+ code: 'protect_check_execution_failed',
+ });
+ }
+
+ const container = await waitForElement(`#${PROTECT_CHECK_MODAL_CONTAINER_ID}`);
+ if (!container) {
+ void clerk.__internal_closeProtectCheckModal();
+ throw new ClerkRuntimeError('Protect check UI failed to open', {
+ code: 'protect_check_execution_failed',
+ });
+ }
+
+ const setWrapperVisible = (visible: boolean) => {
+ const wrapper = document.getElementById(PROTECT_CHECK_MODAL_WRAPPER_ID);
+ wrapper?.style.setProperty('visibility', visible ? 'visible' : 'hidden');
+ wrapper?.style.setProperty('pointer-events', visible ? 'all' : 'none');
+ };
+
+ // Reveal on the first of: the script announcing a visible widget, or the delay elapsing for
+ // a still-running (e.g. proof-of-transfer) check. A `false` counter-signal is ignored — the
+ // modal closes moments later on resolution, and re-hiding a revealed modal mid-submit reads
+ // as a glitch.
+ let revealed = false;
+ const reveal = () => {
+ if (!revealed) {
+ revealed = true;
+ setWrapperVisible(true);
+ }
+ };
+ const revealTimer = setTimeout(reveal, MODAL_REVEAL_DELAY_MS);
+
+ return {
+ container: container as HTMLDivElement,
+ setWidgetVisible: (visible: boolean) => {
+ if (visible) {
+ clearTimeout(revealTimer);
+ reveal();
+ }
+ return Promise.resolve();
+ },
+ close: () => {
+ clearTimeout(revealTimer);
+ void clerk.__internal_closeProtectCheckModal();
+ },
+ };
+ }
+}
diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts
index 4ad63d5d01c..60998654a39 100644
--- a/packages/clerk-js/src/core/resources/Base.ts
+++ b/packages/clerk-js/src/core/resources/Base.ts
@@ -88,7 +88,14 @@ export abstract class BaseResource {
requestInit: FapiRequestInit,
opts: BaseFetchOptions = {},
): Promise | null> {
- return FraudProtection.getInstance().execute(this.clerk, () => this._baseFetch(requestInit, opts));
+ return FraudProtection.getInstance().execute(
+ this.clerk,
+ () => this._baseFetch(requestInit, opts),
+ // Lets the managed Protect challenge gate issue its own PATCH/GET with full resource-call
+ // semantics (client piggyback updates, ClerkAPIResponseError on 4xx) without re-entering
+ // FraudProtection.
+ (init, o) => this._baseFetch(init as FapiRequestInit, o),
+ );
}
// TODO @userland-errors:
diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts
index c11db68f590..77c7f0069c0 100644
--- a/packages/shared/src/internal/clerk-js/constants.ts
+++ b/packages/shared/src/internal/clerk-js/constants.ts
@@ -70,3 +70,9 @@ export const SUPPORTED_FAPI_VERSION = '2026-05-12';
export const CAPTCHA_ELEMENT_ID = 'clerk-captcha';
export const CAPTCHA_INVISIBLE_CLASSNAME = 'clerk-invisible-captcha';
+/**
+ * Placement marker for Protect challenges, mirroring the `clerk-captcha` contract: when an
+ * element with this id exists, challenges render inline into it instead of the managed modal.
+ * The prebuilt protect-check cards use the same id for their container.
+ */
+export const PROTECT_CHECK_ELEMENT_ID = 'clerk-protect-check';