Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0f726f7
feat(clerk-js,shared,ui): Add Protect SDK challenge support during si…
zourzouvillys Apr 16, 2026
f641da5
Merge branch 'main' into theo/protect-check-sdk-support
jacekradko Apr 25, 2026
f013b20
Merge branch 'main' into theo/protect-check-sdk-support
jacekradko Apr 29, 2026
ccab663
Merge remote-tracking branch 'origin/main' into theo/protect-check-sd…
zourzouvillys Jun 4, 2026
ead7059
fix(ui): wire protect-check route into combined sign-in flow; reword …
zourzouvillys Jun 4, 2026
e61ce4e
fix(react): mirror protectCheck/submitProtectCheck on sign-in/up stat…
zourzouvillys Jun 4, 2026
7e14a2e
Merge branch 'main' into theo/protect-check-sdk-support
jacekradko Jun 10, 2026
d3e2c2b
Merge remote-tracking branch 'origin/main' into theo/protect-check-sd…
zourzouvillys Jun 10, 2026
0f1b746
fix(ui): harden protect-check cards via shared runner hook
zourzouvillys Jun 10, 2026
305b7a4
fix(ui): route every sign-in/up protect gate through one choke point
zourzouvillys Jun 10, 2026
66da198
fix(clerk-js): scope redirect-callback protect gate to the flow intent
zourzouvillys Jun 10, 2026
2a9bb5b
docs(shared): document protect_check expires_at as Unix epoch millise…
zourzouvillys Jun 10, 2026
a8c7dff
Merge remote-tracking branch 'origin/theo/protect-check-sdk-support' …
zourzouvillys Jun 10, 2026
dba89a2
Merge remote-tracking branch 'origin/main' into theo/protect-check-sd…
zourzouvillys Jun 10, 2026
3a36fc8
fix(clerk-js): route gated web3 sign-up to the protect-check; address…
zourzouvillys Jun 11, 2026
b745323
chore(ui): raise signup bundlewatch budget to 12KB
zourzouvillys Jun 11, 2026
03b90bb
test(ui): cover the sign-in protect-gate choke point and a call site
zourzouvillys Jun 11, 2026
15eea0e
test(ui): use a consistent email fixture for the protect-gate routing…
zourzouvillys Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/protect-check-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@clerk/clerk-js': minor

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@clerk/react is missing here. stateProxy.ts adds protectCheck/submitProtectCheck to the public state proxies, so that's user-facing surface in @clerk/react that needs a '@clerk/react': minor entry, otherwise it ships with no changelog and only an implicit dependency bump.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added '@clerk/react': minor. You're right — stateProxy.ts puts protectCheck/submitProtectCheck on the public state proxies, so it's user-facing surface that needs its own changelog entry.

'@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 `<SignIn />` and `<SignUp />` components handle the gate automatically by routing
to a new `protect-check` route that runs the challenge SDK and resumes the flow on completion.
91 changes: 91 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down
69 changes: 68 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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);
Expand All @@ -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(
Expand All @@ -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();
}
Expand All @@ -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 }),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think this protectCheckPath is reachable on a gated sign-up. A protect-gated response always carries 'protect_check' in missing_fields, so the if (missingFields.length) check above short-circuits to navigateToContinueSignUp() and OAuth/SAML callbacks land on /continue instead of the challenge. Could we check the gate before that short-circuit and route to protect-check first (same for the signUp.create({ transfer: true }) path)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. navigateToNextStepSignUp now checks signUp.protectCheck || missingFields.includes('protect_check') before the if (missingFields.length) short-circuit, so a gated OAuth/SAML callback routes to /sign-up/protect-check instead of /continue. This covers the signUp.create({ transfer: true }) path (which calls it). I also added an early su.protectCheck check next to the existing si one for a directly-gated sign-up — and scoped both to the callback intent (see the clerk.ts:2463 thread).

navigate,
});
};
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -2702,6 +2744,8 @@ export class Clerk implements ClerkInterface {
strategy,
legalAccepted,
secondFactorUrl,
protectCheckUrl,
signUpProtectCheckUrl,
walletName,
}: ClerkAuthenticateWithWeb3Params): Promise<void> => {
if (!this.client || !this.environment) {
Expand Down Expand Up @@ -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(
Expand All @@ -2756,6 +2809,7 @@ export class Clerk implements ClerkInterface {
);

let signInOrSignUp: SignInResource | SignUpResource;
let viaSignUp = false;
try {
signInOrSignUp = await this.client.signIn.authenticateWithWeb3({
identifier,
Expand All @@ -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,
Expand All @@ -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();
}
Expand All @@ -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();
Expand Down
64 changes: 64 additions & 0 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type {
PhoneCodeFactor,
PrepareFirstFactorParams,
PrepareSecondFactorParams,
ProtectCheckResource,
ResetPasswordEmailCodeFactorConfig,
ResetPasswordParams,
ResetPasswordPhoneCodeFactorConfig,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<SignInResource> => {
debugLogger.debug('SignIn.submitProtectCheck', { id: this.id });
return this._basePatch({
action: 'protect_check',
body: { proof_token: params.proofToken },
});
};

attemptFirstFactor = (params: AttemptFirstFactorParams): Promise<SignInResource> => {
debugLogger.debug('SignIn.attemptFirstFactor', { id: this.id, strategy: params.strategy });
let config;
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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,
};
}
}
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading