Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 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
48d4a00
fix(clerk-js,ui,shared): route protect-check gates through per-mount …
zourzouvillys Jun 26, 2026
1bfc927
fix(ui): don't blank the protect-check card when the check resolves
zourzouvillys Jul 3, 2026
3d499af
chore(ui): bump ui.browser.js bundlewatch limit to 44KB
zourzouvillys Jul 3, 2026
36fd93c
fix(ui): keep standalone sign-up protect-check mounted
mwickett Jul 3, 2026
bf21a73
fix(ui): handle stale standalone sign-up protect-check route
mwickett Jul 3, 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
5 changes: 5 additions & 0 deletions .changeset/protect-check-standalone-signup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---
Comment on lines +1 to +3

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add a real @clerk/ui changeset

This changes user-facing runtime behavior in packages/ui, but the added changeset is empty. The repo instructions only allow an empty changeset for changes with no user-facing impact; without a package entry, @clerk/ui does not get the patch release/changelog entry needed for consumers to receive this protect-check fix through the normal non-major release flow. Please add a patch changeset for @clerk/ui describing the user-visible fix.

Useful? React with 👍 / 👎.


Fix standalone `<SignUp />` Protect checks so the verification card stays mounted while a solved challenge routes to the next step, while stale direct visits to the protect-check route return to the start of the sign-up flow.
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
'@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
73 changes: 72 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,11 @@ export class Clerk implements ClerkInterface {
buildURL({ base: displayConfig.signInUrl, hashPath: '/reset-password' }, { stringify: true }),
);

const navigateToSignInProtectCheck = makeNavigate(
params.signInProtectCheckUrl ||
buildURL({ base: displayConfig.signInUrl, hashPath: '/protect-check' }, { stringify: true }),
);

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

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

const navigateToSignUpProtectCheck = makeNavigate(
params.signUpProtectCheckUrl ||
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 +2461,9 @@ export class Clerk implements ClerkInterface {
verifyPhonePath:
params.verifyPhoneNumberUrl ||
buildURL({ base: displayConfig.signUpUrl, hashPath: '/verify-phone-number' }, { stringify: true }),
protectCheckPath:
params.signUpProtectCheckUrl ||
buildURL({ base: displayConfig.signUpUrl, hashPath: '/protect-check' }, { stringify: true }),
navigate,
});
};
Expand Down Expand Up @@ -2492,11 +2514,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 +2801,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 +2845,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 +2866,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 +2876,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 +2889,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 +2913,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