Skip to content

feat(clerk-js,shared,ui): Add Protect SDK challenge support during sign-up and sign-in#8329

Open
zourzouvillys wants to merge 23 commits into
mainfrom
theo/protect-check-sdk-support
Open

feat(clerk-js,shared,ui): Add Protect SDK challenge support during sign-up and sign-in#8329
zourzouvillys wants to merge 23 commits into
mainfrom
theo/protect-check-sdk-support

Conversation

@zourzouvillys

@zourzouvillys zourzouvillys commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds client-side support for Clerk Protect mid-flow SDK challenges (protect_check) during both sign-up and sign-in. When the antifraud service gates a step, the SDK exposes the challenge, surfaces a card that loads and runs the challenge script, submits the resulting proof token, and resumes the original flow.

  • New protectCheck field and submitProtectCheck() method on both SignUp and SignIn resources (and their future variants), mirrored on the @clerk/react state proxies.
  • New 'needs_protect_check' value on the SignInStatus union.
  • New protect-check route on the prebuilt <SignIn /> and <SignUp /> components (standalone, continue, and combined-flow create / create/continue depths).

Background

Previously anti-fraud blocks could only happen at sign-in/sign-up create time. This mechanism lets the service gate at any step. When gated, the response carries:

{
  "protect_check": {
    "status": "pending",
    "token": "<challenge token>",
    "sdk_url": "https://.../sdk.js",
    "expires_at": 1700000000000,
    "ui_hints": { "reason": "device_new" }
  }
}

expires_at is a Unix epoch timestamp in milliseconds (documented on the type). The client loads the SDK at sdk_url, runs the challenge with token, and submits the proof token to PATCH /v1/client/sign_{ins,ups}/{id}/protect_check. The response clears the gate, issues a chained challenge, or completes the flow.

Implementation

Types (@clerk/shared)

  • ProtectCheckJSON / ProtectCheckResource { status: 'pending', token, sdkUrl, expiresAt?, uiHints? }; expires_at is optional on both SignUpJSON and SignInJSON (older FAPI versions omit it).
  • 'protect_check' added to SignUpField; 'needs_protect_check' added to SignInStatus.
  • submitProtectCheck added to the sign-up/sign-in resource + future interfaces.

Core resources (@clerk/clerk-js)

  • SignUp / SignIn expose protectCheck and submitProtectCheck({ proofToken }); fromJSON / __internal_toSnapshot round-trip the field; future variants mirror the API.

SDK loader helper (@clerk/shared/internal/clerk-js/protectCheck)

executeProtectCheck(protectCheck, container, { signal }) — validates sdkUrl (must be https:, no credentials, rejects data:/blob:/javascript:), runs the spec-compliant script contract (container, { token, uiHints, signal }), forwards the AbortSignal, and wraps failures in typed error codes without leaking the URL.

Shared card runner (@clerk/ui)

Both protect-check cards share one useProtectCheckRunner hook so the lifecycle can't drift:

  • Keys the effect on protectCheck.token (not object identity) so an unrelated resource refresh doesn't restart the challenge.
  • Caps expired-challenge reloads and fails loud instead of spinning (a plain GET doesn't re-mint).
  • Wraps the script run in a timeout, and the error state offers a retry control.
  • Fails closed in no-RHC builds (__BUILD_DISABLE_RHC__) before the remote import(sdk_url) — the guard lives in the component layer because @clerk/shared is compiled once with the flag false.
  • Finalizes (setActive) the complete case from both the normal success and the protect_check_already_resolved reload, so neither strands the user.
  • Loading state uses a descriptors.spinner spinner in an aria-live region.

Sign-in gate routing — single choke point

navigateOnSignInProtectGate(res, navigate, protectCheckPath) is the one place that turns a gated sign-in response into navigation. Every dispatch site routes through it (start ×2, passkey, password, code, alt-channel, backup-code, factor-two code, reset-password), with the protect-check path passed per caller (index route → 'protect-check', factor cards → '../protect-check'). Also wired into the previously-missed email-link result handler and the inline web3/Solana path (clerk.authenticateWithWeb3, which doesn't redirect through _handleRedirectCallback): it takes protectCheckUrl / signUpProtectCheckUrl params and routes a gated attempt to the sign-in or sign-up challenge depending on which resource the attempt resolved through (the identifier_not_found → signUp fallback is covered).

OAuth / SAML callback (clerk.ts)

_handleRedirectCallback checks the gate before its transfer/missing-fields logic, scoped to the callback intent (reloadResource) so an abandoned sign-in's stale protect_check can't hijack a sign-up callback (and vice versa). The sign-up gate check runs before the missing_fields short-circuit so a gated signUp.create({ transfer }) routes to the challenge instead of /continue.

Prebuilt UI routes (@clerk/ui)

protect-check routes registered on <SignIn />/<SignUp /> at every depth the flow can mount sign-up at; SignUpProtectCheck takes per-mount continuation paths (the continue-nested mounts pass continuePath='..').

Localization (@clerk/localizations, @clerk/shared)

Typed signUp.protectCheck.{title,subtitle,loading,retryButton} / signIn.protectCheck.* keys and unstable__errors entries for the runtime error codes (protect_check_execution_failed, …_invalid_script, …_invalid_sdk_url, …_script_load_failed, …_timed_out, …_unsupported_environment; …_aborted / …_already_resolved intentionally undefined).

Backwards compatibility

  • All new JSON fields are optional; old SDK consumers ignore them.
  • 'needs_protect_check' is type-additive — runtime behavior is unchanged (the server emits it only behind a feature gate, and protectCheck is the authoritative field). Strict-TypeScript consumers with an exhaustive switch (signIn.status) will get a new unhandled-branch hint, hence the minor bump.
  • No existing API surface is removed.

Risks

  • Custom flows that switch on signIn.status need to handle 'needs_protect_check' (or the protectCheck field). Documented on the resource interface.
  • Challenge SDK contract — the loaded script must default-export (container, { token, uiHints, signal }) => Promise<string>. Coordinate with the Protect SDK team before deploying.
  • CSP — apps with strict CSP must allow the Protect script origin via script-src; the load-failure error calls this out.

Test plan

  • Unit (resources): SignUp.test.ts / SignIn.test.ts — serialization, optional fields, snapshot round-trip, submitProtectCheck path/method/body
  • Unit (helper): protectCheck.test.ts — URL validation, script contract, cancellation, error wrapping
  • Unit (flow): completeSignUpFlow.test.ts — routing priority
  • Unit (redirect): clerk.test.ts — gate routing scoped to the callback intent (stale sign-in not picked up by a sign-up callback; sign-in callback routes to the gate)
  • Unit (choke point): handleProtectCheck.test.tsnavigateOnSignInProtectGate / isSignInProtectGated (both gate signals, per-caller path, no navigation when ungated)
  • Integration (call site): SignInFactorOne.test.tsx — a gated first-factor attempt routes to ../protect-check instead of dispatching on the underlying status
  • Component: SignUpProtectCheck.test.tsx / SignInProtectCheck.test.tsx — run/expiry/already-resolved/chained/abort/no-submit-on-failure, finalize-on-reload-complete, retry control
  • Build + type-check: @clerk/clerk-js, @clerk/shared, @clerk/localizations, @clerk/ui clean; lint clean
  • Manual: drive a sign-up/sign-in on a Protect-enabled instance (challenge renders + resolves, chained challenge, expired auto-recovery, OAuth/SAML callback)

Follow-ups (out of scope)

  • Server-side ownership of re-minting an expired challenge on read (vs. re-running the gated step) — capped client-side so it can't loop in the meantime.
  • Additional test coverage (lower priority): a dedicated authenticateWithWeb3 sign-up-gate regression test, an email-link gate-routing test, and the hook's no-RHC / timeout branches (not exercisable in the current ui vitest setup).
  • @clerk/backend resource model updates (the backend SDK doesn't drive end-user flows).
  • Non-blocking protect_check (additive when the server starts emitting it).

Summary by CodeRabbit

  • New Features

    • Clerk Protect mid-flow challenge support for sign-up and sign-in with automatic routing in pre-built flows (including Web3 and passkey), navigation guards, and routing for chained challenges
    • Added protectCheck state, submitProtectCheck APIs, and new needs_protect_check sign-in status
    • New ProtectCheck UI components, routing steps, and a shared hook to run/retry/cancel/resume challenges
  • Localization

    • Added protect-check UI strings and new protect-check error messages
  • Tests

    • Extensive tests covering flows, SDK execution, cancellation, chaining, routing, and edge cases

@vercel

vercel Bot commented Apr 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Jun 26, 2026 10:01pm
swingset Ready Ready Preview, Comment Jun 26, 2026 10:01pm

Request Review

@changeset-bot

changeset-bot Bot commented Apr 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 48d4a00

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 23 packages
Name Type
@clerk/clerk-js Minor
@clerk/localizations Minor
@clerk/react Minor
@clerk/shared Minor
@clerk/ui Minor
@clerk/chrome-extension Patch
@clerk/electron Patch
@clerk/expo Patch
@clerk/nextjs Patch
@clerk/react-router Patch
@clerk/tanstack-react-start Patch
@clerk/astro Patch
@clerk/backend Patch
@clerk/expo-passkeys Patch
@clerk/express Patch
@clerk/fastify Patch
@clerk/headless Patch
@clerk/hono Patch
@clerk/msw Patch
@clerk/nuxt Patch
@clerk/testing Patch
@clerk/vue Patch
@clerk/swingset Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

…gn-up and sign-in

Adds client-side support for mid-flow SDK challenges issued by the antifraud
service during sign-up and sign-in.

- New `protectCheck` field and `submitProtectCheck()` method on SignUp and SignIn resources
- New `'needs_protect_check'` value on the SignInStatus union
- New `protect-check` route on the prebuilt `<SignIn />` and `<SignUp />` components
  that loads the challenge SDK, submits the proof token, and resumes the flow
Comment thread packages/ui/src/components/SignIn/shared.ts Outdated
Comment thread packages/ui/src/components/SignIn/index.tsx
Comment thread packages/clerk-js/src/core/clerk.ts Outdated
@jacekradko

jacekradko commented Apr 29, 2026

Copy link
Copy Markdown
Member

@zourzouvillys The core stuff looks good. I think the biggest gap is the routing logic integration. Feels like it this is targeting the standalone <SignIn /> / <SignUp /> , but the combined flows are not hooked up properly.

…k-support

# Conflicts:
#	packages/shared/src/types/signInFuture.ts
#	packages/shared/src/types/signUpCommon.ts
#	packages/shared/src/types/signUpFuture.ts
#	packages/ui/src/elements/contexts/index.tsx
The needs_protect_check status and protectCheck field are only returned
when Protect mid-flow challenges are explicitly enabled for an instance;
upgrading the SDK alone changes nothing at runtime. State this in the
changeset (with what to do when an exhaustive switch flags the new
status) and on every typedoc surface: the SignInStatus list on the
future resource, the status/protectCheck properties on both resources
and future variants, and the ProtectCheckResource interface.
@zourzouvillys

Copy link
Copy Markdown
Contributor Author

@Ephem Yep, exactly right — the npm upgrade itself is type-only. The runtime change only ever happens when Protect mid-flow challenges are explicitly enabled for an instance: it's currently behind a flag and not enabled for existing instances, so nothing turns on by itself. There's a second layer too — the server only emits the new status value to SDK versions that understand it, so older clients never receive an unknown status either way.

Good call on the changeset — "surfaced when the server-side SDK-version gate is enabled" didn't really tell an upgrading user what they needed to know. Pushed c2795e4:

  • Changeset now says explicitly: upgrading is type-only, the feature is off by default and must be explicitly enabled per instance, and if an exhaustive switch on signIn.status flags the new value, handle it via protectCheck + submitProtectCheck() (the prebuilt components do it automatically).
  • Typedoc: there was some on protectCheck/submitProtectCheck but nothing on the status itself — added 'needs_protect_check' to the documented SignInStatus list and put the same "only when explicitly enabled for the instance; upgrading alone doesn't enable it" note on the status/protectCheck properties and ProtectCheckResource.

@Ephem

Ephem commented Jun 15, 2026

Copy link
Copy Markdown
Member

@zourzouvillys Sounds great, thanks! 🙏

Resolve conflicts:
- ui/elements/contexts: keep main's ssoConfirmation->ssoActivate rename and
  our added 'protectCheck' flow part.
- references/mosaic-architecture.md: take main's clean fix for the orphan code
  fence (drop our stray four-backtick fence).
- ui/bundlewatch.config.json: signup budget 13KB (main's value covers the
  merged bundle, measured 11.6KB); bump signin budget 16KB->17KB because the
  merged signin bundle (16.23KB) now carries both main's OAuth-transport growth
  and our SignInProtectCheck card.

Claude-Session: https://claude.ai/code/session_01AgSx5coETQG4ShH1qWSYVd
The merged clerk.legacy.browser.js bundle is 114.4KB (over the 114KB budget)
now that the protect-check core code (SignUp/SignIn protectCheck +
submitProtectCheck, clerk.ts gate routing) lands alongside main's growth.
Measured locally; modern clerk.browser.js stays within its 74KB budget.

Claude-Session: https://claude.ai/code/session_01AgSx5coETQG4ShH1qWSYVd
),
);

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?

@jacekradko

Copy link
Copy Markdown
Member

Trying to trace how the protect gate interacts with the MFA email-link card. The other second-factor cards route through it now, but SignInFactorTwoEmailLinkCard looks like it calls setActive directly in handleVerificationResult without the gate check. If Protect can gate that step, would createdSessionId be null here and send us down the sign-out path instead of the challenge? Wondering if it got skipped because this card doesn't go through completeSignInFlow like the rest.

customNavigate: router.navigate,
redirectUrl: ctx.afterSignInUrl || '/',
secondFactorUrl: 'factor-two',
protectCheckUrl: 'protect-check',

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.

isn't this card mounted under choose-wallet? so the bare protect-check would resolve to /sign-in/choose-wallet/protect-check, not the actual route.

.then(async res => {
await resolve();

if (navigateOnSignInProtectGate(res, navigate, '../protect-check')) {

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.

The attempt is gated here, but the resend and initial prepare just above drop the response. Can a protect gate come back on prepare (the code-send itself)? The docstring on navigateOnSignInProtectGate lists prepare as a call site, so these might need to route through it too, unless the server never gates that step.

The dynamic-import failure message embeds the sdk_url in Chromium/Firefox; stop interpolating it into the user-facing ClerkRuntimeError, and make the test assert the invariant rather than relying on Node's URL-free import error.
@jacekradko

Copy link
Copy Markdown
Member

The routing tests mostly take the path as an argument and assert it back, so they can't catch a wrong literal when invoked. SignInFactorOne is the only render test pinning a real path, which is why the combined-flow OAuth sign-up gate and the Solana wallet gate both slipped through pointing at the wrong route. A render test per entry point asserting the actual navigate(...) would catch this class.

Comment thread references/mosaic-architecture.md Outdated
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.

setIsRunning(true);
void (async () => {
try {
await reload();

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.

After the reload, only the still-expired case is handled. If the reload instead clears the gate or completes the flow, nothing calls onResolved, so the route guard bounces to flow start instead of continuing (and a completed sign-in wouldn't get setActive). Worth routing on the refreshed resource here, not just failing when still expired.

Comment on lines +102 to +112
// 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;
}

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 expect this will still get flagged, even though the import() is dead code. One solution would be to load executeProtectCheck() async behind the build-time flag.

…overrides; address review

Addresses review feedback on #8329:

- OAuth/SAML callback: add signInProtectCheckUrl/signUpProtectCheckUrl overrides
  to HandleOAuthCallbackParams and thread them through the callback builders,
  SignIn context, standalone SignUp SSO callback, and Google One Tap, so a gated
  sign-up in the combined flow stays inside <SignIn/> instead of ejecting to the
  standalone /sign-up.
- Solana wallet card: fix protect-check (and the pre-existing second-factor /
  continue) targets to be relative to the choose-wallet mount.
- Route the first-factor prepare/resend and the 2FA email-link result through the
  protect gate choke point so a mid-flow gate isn't dropped.
- useProtectCheckRunner: route on the refreshed resource when an expired-challenge
  reload clears the gate or completes the flow (keyed on mount, not the effect
  cancel flag); lazy-load executeProtectCheck behind __BUILD_DISABLE_RHC__ so the
  remote import is tree-shaken out of no-RHC builds.
- Add per-entry-point render tests (prepare gate, Solana card, 2FA email link,
  expired-reload-clears-gate) and lock the new callback-param literals.
- Revert a stray mosaic-architecture.md edit.

Claude-Session: https://claude.ai/code/session_01Qy3HfvkryrkWfx9qjEFHkM
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants