Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 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
c2795e4
docs(shared): clarify protect-check gating in changeset and typedoc
zourzouvillys Jun 13, 2026
dcefd05
Merge branch 'main' into theo/protect-check-sdk-support
zourzouvillys Jun 25, 2026
3963323
chore(js): raise clerk.legacy.browser bundlewatch budget to 116KB
zourzouvillys Jun 25, 2026
bb425b3
fix(shared): keep protect-check sdk_url out of the load-failure error
jacekradko Jun 26, 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
30 changes: 30 additions & 0 deletions .changeset/protect-check-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@clerk/clerk-js': minor
Comment thread
jacekradko marked this conversation as resolved.
'@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. **Upgrading this
package is type-only and does not change runtime behavior**: the server returns the new status
(and the `protectCheck` field) only for instances where Protect mid-flow challenges have been
explicitly enabled — the feature is off by default and is not enabled for existing instances by
upgrading. The server additionally only emits the new status value to SDK versions that
understand it, so older clients never receive an unknown status.

If an exhaustive `switch` on `signIn.status` flags the new value after upgrading, handle it by
running the challenge described by `protectCheck` and submitting the proof via
`submitProtectCheck()`. 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.
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"files": [
{ "path": "./dist/clerk.js", "maxSize": "549KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "74KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "116KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
Expand Down
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 @@ -2256,6 +2256,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 @@ -2379,6 +2379,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 @@ -2387,6 +2388,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 @@ -2410,6 +2412,10 @@ export class Clerk implements ClerkInterface {
buildURL({ base: displayConfig.signInUrl, hashPath: '/reset-password' }, { stringify: true }),
);

const navigateToSignInProtectCheck = makeNavigate(

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.

This one has the same gap as the sign-up nav below, and it generalizes: the factor/continue/verify navs all honor a params.*Url override, but both protect-check navs always use the displayConfig URL. A custom-mounted or path-based flow keeps every step in context except the gate. There's also no protect-check field on the callback params to override it.

buildURL({ base: displayConfig.signInUrl, hashPath: '/protect-check' }, { stringify: true }),
);

const redirectUrls = new RedirectUrls(this.#options, params);

const navigateToContinueSignUp = makeNavigate(
Expand All @@ -2423,7 +2429,18 @@ export class Clerk implements ClerkInterface {
),
);

const navigateToSignUpProtectCheck = makeNavigate(

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.

Was working through this with Claude and it flagged that in combined flow, a Protect-gated OAuth/SAML sign-up routes to the standalone /sign-up#/protect-check instead of /sign-in#/create/protect-check, popping the user out of <SignIn/>. Looks like navigateToSignUpProtectCheck uses the absolute displayConfig.signUpUrl with no relative override, unlike the web3 path / continueSignUpUrl. Can you take a look?

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 @@ -2442,6 +2459,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 @@ -2492,11 +2510,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 @@ -2755,6 +2797,8 @@ export class Clerk implements ClerkInterface {
strategy,
legalAccepted,
secondFactorUrl,
protectCheckUrl,
signUpProtectCheckUrl,
walletName,
}: ClerkAuthenticateWithWeb3Params): Promise<void> => {
if (!this.client || !this.environment) {
Expand Down Expand Up @@ -2797,6 +2841,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 @@ -2809,6 +2862,7 @@ export class Clerk implements ClerkInterface {
);

let signInOrSignUp: SignInResource | SignUpResource;
let viaSignUp = false;
try {
signInOrSignUp = await this.client.signIn.authenticateWithWeb3({
identifier,
Expand All @@ -2818,6 +2872,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 @@ -2830,7 +2885,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 @@ -2851,6 +2909,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
Loading
Loading