') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(clerk-js,shared,ui): Add Protect SDK challenge support during sign-up and sign-in by zourzouvillys · Pull Request #8329 · clerk/javascript · GitHub
Skip to content

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

Merged
wobsoriano merged 26 commits into
mainfrom
theo/protect-check-sdk-support
Jul 6, 2026
Merged

feat(clerk-js,shared,ui): Add Protect SDK challenge support during sign-up and sign-in#8329
wobsoriano merged 26 commits into
mainfrom
theo/protect-check-sdk-support

Conversation

@zourzouvillys

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

vercelBot commented Apr 16, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentJul 6, 2026 3:32pm
swingsetReadyReadyPreview, CommentJul 6, 2026 3:32pm

Request Review

@changeset-bot

changeset-botBot commented Apr 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a60d2bb

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

This PR includes changesets to release 23 packages
NameType
@clerk/clerk-jsMinor
@clerk/localizationsMinor
@clerk/reactMinor
@clerk/sharedMinor
@clerk/uiMinor
@clerk/chrome-extensionPatch
@clerk/electronPatch
@clerk/expoPatch
@clerk/nextjsPatch
@clerk/react-routerPatch
@clerk/tanstack-react-startPatch
@clerk/astroPatch
@clerk/backendPatch
@clerk/expo-passkeysPatch
@clerk/expressPatch
@clerk/fastifyPatch
@clerk/headlessPatch
@clerk/honoPatch
@clerk/mswPatch
@clerk/nuxtPatch
@clerk/testingPatch
@clerk/vuePatch
@clerk/swingsetPatch

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 threadpackages/ui/src/components/SignIn/shared.ts Outdated
Comment threadpackages/ui/src/components/SignIn/index.tsx
Comment threadpackages/clerk-js/src/core/clerk.ts Outdated
@jacekradko

jacekradko commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

@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
setIsRunning(true);
void (async () => {
try {
await reload();

Copy link
Copy Markdown
Contributor

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
ContributorAuthor

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
ContributorAuthor

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
ContributorAuthor

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.

@jacekradkojacekradko left a comment

Copy link
Copy Markdown
Contributor

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

5 participants

@zourzouvillys@jacekradko@Ephem@brkalow@wobsoriano