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 25 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 25 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 Jul 3, 2026 1:45am
swingset Ready Ready Preview, Comment Jul 3, 2026 1:45am

Request Review

@changeset-bot

changeset-bot Bot commented Apr 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3d499af

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
@zourzouvillys zourzouvillys requested a review from brkalow June 25, 2026 17:57
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?

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 — fixed in 48d4a00df. navigateToSignUpProtectCheck now honors a new params.signUpProtectCheckUrl override (falling back to the displayConfig URL as before). The SignIn context builds it off signUpUrl, which is rewritten to <signInUrl>#/create in the combined flow, so a gated combined-flow OAuth/SAML sign-up now lands on …/create/protect-check and stays inside <SignIn/> instead of popping out to the standalone /sign-up.

@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.

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.

Right — this card mounts at /sign-in/choose-wallet, so the bare paths were off by one level (they were copied verbatim from SignInSocialButtons, which lives at the sign-in root). Fixed in 48d4a00df: protectCheckUrl../protect-check and signUpProtectCheckUrl../create/protect-check. I also fixed the pre-existing secondFactorUrl/signUpContinueUrl, which had the same off-by-one from #7450 — happy to pull those two back out if you'd rather keep this PR strictly protect-check. Added a render test asserting the literal params forwarded to authenticateWithWeb3.

.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.

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.

Routed both prepare and the initial useFetch prepare through navigateOnSignInProtectGate in 48d4a00df (resend reuses the same prepare handler, so it's covered too). It's a no-op when the response isn't gated, so it's safe whether or not the server actually gates the prepare step — defensive and matches the docstring's call-site list. Added a render test driving the fetch onSuccess with a gated response.

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.

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.

Agreed, generalized it. Both protect-check navs now take per-caller overrides — signInProtectCheckUrl / signUpProtectCheckUrl added to HandleOAuthCallbackParams, mirroring the existing firstFactorUrl/continueSignUpUrl pattern. They're threaded through the callback builders (buildOAuthCallbackParams), the SignIn context, the standalone SignUp SSO callback, and Google One Tap, so a custom-mounted / path-based flow keeps the gate in context too. Fixed in 48d4a00df.

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.

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.

Fixed in 48d4a00df. After the expired-challenge reload, if the gate clears or the flow completes we now route on the refreshed resource (so a completed sign-in still gets setActive instead of the route guard bouncing to flow start).

One subtlety worth calling out: clearing protectCheck flips the effect's token dependency, which re-runs and cancels the in-flight reload effect — so I keyed the routing on a separate mountedRef (true until real unmount) rather than the per-run cancelled flag. Otherwise that re-run would abort the very routing it's supposed to trigger. Added a test for the expired-reload-clears-gate case.

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.

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.

Done in 48d4a00dfexecuteProtectCheck is now lazy-import()ed inside runChallenge, gated on __BUILD_DISABLE_RHC__. In no-RHC builds the flag is true, so the branch (and the dynamic import below it) is dead-code-eliminated — the loader and its remote import(sdk_url) are tree-shaken out of those bundles entirely rather than shipped-but-unused, instead of being pulled in by the old static top-level import.

…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
@zourzouvillys

Copy link
Copy Markdown
Contributor Author

Pushed 48d4a00df addressing this round of feedback. Replies inline on each thread; two things that came up as top-level comments rather than inline:

SignInFactorTwoEmailLinkCard finalizing without the gate check — confirmed: it finalizes inline (doesn't go through completeSignInFlow like the other second-factor cards), so a gate would have hit setActive with a null createdSessionId. Added a gate check in handleVerificationResult before the setActive, routing to ../protect-check when gated. Covered by a new render test that mocks the email-link flow resolving gated.

Test gap (wrong-literal slips through arg-passthrough tests) — agreed. Added per-entry-point render/assertion tests that pin the actual target rather than echoing the passed arg:

  • SignInFactorOneSolanaWalletsCard — asserts the literal params forwarded to authenticateWithWeb3 (../protect-check, ../factor-two).
  • SignInFactorOneCodeForm — drives the prepare onSuccess with a gated response, asserts navigate('../protect-check').
  • SignInFactorTwoEmailLinkCard — asserts navigate('../protect-check') + no setActive.
  • SignInProtectCheck — expired-reload-clears-gate routes on the refreshed resource.
  • buildOAuthCallbackParams — locked the new signInProtectCheckUrl/signUpProtectCheckUrl literals for every callback variant.

Verification: 124 UI + 275 clerk-js tests pass · @clerk/ui typecheck clean · clerk-js declarations build clean · 0 lint errors.

One open call: I also fixed the pre-existing secondFactorUrl/signUpContinueUrl off-by-one in the Solana card (same root cause from #7450) — flagged on that thread; easy to pull back out if you'd prefer to keep this PR strictly protect-check.

@jacekradko jacekradko left a comment

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 think this is in a good enough place to merge 👍🏼

The protect-check routes guarded on `canActivate={!!signIn.protectCheck}` (and
the signUp equivalent). But submitProtectCheck clears protectCheck on the
resolving PATCH, so the guard flipped false at the exact moment the card was
navigating to the next step: RouteGuard unmounted the card and redirected to
flow-start, leaving a blank #/protect-check. Unlike the sibling
verify-email/verify-phone steps, protect-check clears its own prerequisite as it
succeeds, so the shared "guard on prerequisite presence" convention broke it.

- Remove the canActivate guard from all three protect-check routes (top-level
  sign-in, create, create/continue). The card owns its own post-resolution
  routing; the verify-* siblings keep their guards (they don't clear their field).
- useProtectCheckRunner: route on real unmount (mountedRef), not the per-run
  `cancelled` flag. Clearing protectCheck re-runs the effect (which sets
  `cancelled`), and that re-run is the cue to route — it must not abort the
  navigation. Applies the expired-reload path's existing rationale to the
  success + already-resolved paths.

Claude-Session: https://claude.ai/code/session_01RWf2ttyUV8EnqhTBazHuZC
The protect-check SDK support grew ui.browser.js to 42.04KB gzip, just over the
42KB limit. Bump to 44KB, matching the bundlewatch:fix convention
(ceil(size + 1KB)), so the feature's code fits with headroom.

Claude-Session: https://claude.ai/code/session_01RWf2ttyUV8EnqhTBazHuZC
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