Skip to content

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

Draft
zourzouvillys wants to merge 1 commit intomainfrom
theo/protect-check-sdk-support
Draft

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

Conversation

@zourzouvillys
Copy link
Copy Markdown
Contributor

@zourzouvillys zourzouvillys commented Apr 16, 2026

Summary

Adds client-side support for Clerk Protect mid-flow SDK challenges during both sign-up and sign-in. When the antifraud service issues a challenge, the SDK exposes the challenge data, surfaces a UI to load and execute the challenge script, and resolves the gate via a dedicated endpoint.

  • New top-level protectCheck field and submitProtectCheck() method on both 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 original flow

Background

Previously, anti-fraud blocks could only happen at sign-in/sign-up create time. The new mechanism allows the service to gate at any step (e.g., between identifier and first-factor, before sending an SMS code, before finalizing). 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" }
  }
}

The client loads the SDK at sdk_url, executes the challenge with token, and submits the resulting proof token to PATCH /v1/client/sign_{ins,ups}/{id}/protect_check. The response either clears the gate, issues a chained challenge, or completes the flow.

A previous attempt at SDK support (#7894) targeted an earlier server API and was closed. This PR targets the current backend contract.

Implementation

Type additions (@clerk/shared)

  • ProtectCheckJSON / ProtectCheckResource with fields { status: 'pending', token, sdkUrl, expiresAt?, uiHints? }
  • protect_check?: ProtectCheckJSON | null on SignUpJSON and SignInJSON
  • 'protect_check' added to SignUpField (so it appears in missing_fields for sign-up)
  • 'needs_protect_check' added to SignInStatus
  • New submitProtectCheck method on SignUpResource, SignUpFutureResource, SignInResource, SignInFutureResource

Core resources (@clerk/clerk-js)

  • SignUp and SignIn now expose protectCheck and submitProtectCheck({ proofToken }) (PATCH .../protect_check)
  • fromJSON / __internal_toSnapshot round-trip the field
  • SignUpFuture / SignInFuture mirror the API for the experimental hooks

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

Single shared helper used by both UI components:

executeProtectCheck(
  protectCheck: { sdkUrl, token, uiHints },
  container: HTMLDivElement,
  options?: { signal?: AbortSignal },
): Promise<string>
  • URL validationsdkUrl must parse, must be https:, must not contain credentials. Rejects data: / blob: / javascript: and credentialed URLs at the helper boundary (fail-closed: the gate stays present, the user can't bypass it).
  • Spec-compliant script contract — the script receives only (container, { token, uiHints, signal }), NOT the full sign-up/sign-in resource. This matches FAPI spec §5.2 and minimizes the trust surface granted to third-party Protect scripts.
  • AbortSignal forwarding — the signal is passed to the script for cooperative cancellation, and re-checked after the script's await so an uncooperative SDK can't return a token after abort.
  • Error codesprotect_check_invalid_sdk_url, protect_check_aborted, protect_check_script_load_failed (with CSP-aware guidance), protect_check_invalid_script, protect_check_execution_failed. Error messages do NOT include sdkUrl (avoids exposing an attacker-controlled URL in the auth UI).

Flow orchestration

  • completeSignUpFlow adds a new protectCheckPath parameter and routes to it when protectCheck is present (or 'protect_check' is in missing_fields)
  • clerk._handleRedirectCallback (OAuth/SAML callback path) checks for the gate after the callback resolves (per spec §4.4)
  • All sign-in operation dispatch points detect the gate via a small isSignInProtectGated() helper and route to protect-check. Wired into 8 places: SignInStart (×2), SignInFactorOnePasswordCard, SignInFactorOneCodeForm, SignInFactorOneAlternativeChannelCodeForm, SignInFactorTwoBackupCodeCard, SignInFactorTwoCodeForm, ResetPassword, and the passkey handler in shared.ts. Web3 / OAuth / SAML / Solana sign-in paths use authenticateWithRedirect (which redirects out of the SDK), so any post-redirect gate is caught by the centralized _handleRedirectCallback check in clerk.ts.

Prebuilt UI (@clerk/ui)

  • New protect-check route on both <SignUp /> and <SignIn />, gated by canActivate: clerk => !!clerk.client.signUp.protectCheck (or signIn)
  • New SignUpProtectCheck / SignInProtectCheck card components that:
    • Call executeProtectCheck with the protect_check object and a ref-attached container div
    • Submit the resulting proof token via submitProtectCheck({ proofToken })
    • On expired expiresAt: reload the resource so the server mints a fresh challenge (avoids infinite loop on stale local state)
    • On chained challenges (response still has protectCheck): self-navigate to . to re-run
    • On protect_check_already_resolved (HTTP 400): reload the resource, then route based on the refreshed status
    • On completion: setActive (sign-in) or finalize via completeSignUpFlow (sign-up)
    • Use AbortController + a cancelled flag, gating every state update after every await so unmount-mid-challenge doesn't trigger React-state-on-unmounted-component warnings or stray network calls
  • The SignInProtectCheck navigateNext helper handles 'needs_protect_check' explicitly (self-navigates) for forward compatibility with non-blocking checks

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

  • New typed keys signUp.protectCheck.{title,subtitle,loading} and signIn.protectCheck.{title,subtitle,loading} in the __internal_LocalizationResource schema with English source values in en-US.ts
  • New unstable__errors entries for the runtime error codes the helper can produce: action_blocked, protect_check_aborted (intentionally undefined — user-cancelled, not surfaced), protect_check_already_resolved (intentionally undefined — soft-success), protect_check_execution_failed, protect_check_invalid_script, protect_check_invalid_sdk_url, protect_check_script_load_failed. The error-code lookup is automatic via translateError() — other locales can opt in by adding their own translations, otherwise they fall back to the English source.

Backwards compatibility

  • All new JSON fields are optional. Old SDK consumers ignore the new protect_check field on responses.
  • Older clients hitting newer servers continue to work — the server emits 'needs_protect_check' only when a feature gate matches, falling back to the underlying status otherwise.
  • The 'needs_protect_check' addition to SignInStatus is type-additive but will surface as a new exhaustive-switch branch for downstream consumers using strict TypeScript.
  • No existing API surface is removed.

Risks

  • Custom flows (non-prebuilt UI) that switch on signIn.status need to handle 'needs_protect_check' (or the protectCheck field) themselves. Without handling, the UI will appear stuck at the previous step. The new fields are documented on the SignInResource interface.
  • Challenge SDK contract — the loaded script must export a default function (container, { token, uiHints, signal }) => Promise<string>. The container is a plain <div> mounted inside the Clerk card; the Protect SDK is responsible for rendering inside it (including any iframe sandboxing the SDK chooses to apply). Coordinate the contract with the Protect SDK team before deploying.
  • CSP — apps with strict CSP must allow the Protect script origin via script-src. The helper's load-failure error message explicitly calls this out.
  • OAuth / SAML callbacks — the protect_check check now runs in _handleRedirectCallback before the existing transfer logic. Behavioral parity verified against the existing transfer paths.
  • 8 sign-in dispatch points are wired explicitly (see implementation section). Web3 / OAuth / SAML / Solana use authenticateWithRedirect and are caught after the redirect via _handleRedirectCallback.

Test plan

  • Unit (resources): SignUp.test.ts — 4 new tests for serialization, optional fields, snapshot round-trip, submitProtectCheck API call
  • Unit (resources): SignIn.test.ts — 5 new tests for the same surface
  • Unit (helper): protectCheck.test.ts — 14 new tests covering URL validation (HTTPS, no credentials, no data:/javascript:), script invocation contract (only spec-defined fields passed), cancellation (pre-load, mid-execution, uncooperative SDK), error wrapping (load / invalid-script / execution failure / no URL leakage)
  • Unit (flow): completeSignUpFlow.test.ts — 4 new tests for routing behavior (missing-field signal, field signal, priority over enterprise_sso, fallback when no path provided)
  • Component: SignUpProtectCheck.test.tsx — 7 tests (renders, runs SDK, expiry → reload, protect_check_already_resolved → reload + continue, chained challenge self-navigation, abort on unmount, no-submit on SDK failure)
  • Component: SignInProtectCheck.test.tsx — 9 tests (same as sign-up plus status routing for needs_first_factor, needs_second_factor, complete/finalize)
  • Build: @clerk/clerk-js, @clerk/shared, @clerk/localizations, @clerk/ui all build clean
  • Lint: clean (no new warnings)
  • Manual: drive through a sign-up/sign-in with a Protect-enabled instance and confirm the challenge UI renders and resolves
  • Manual: verify chained challenge handling (server issues a second challenge after the first proof)
  • Manual: verify expired-challenge auto-recovery (set a short expiry, wait, retry — should reload and re-mint)
  • Manual: verify the OAuth/SAML callback path with a gating instance

Out of scope (follow-ups)

  • @clerk/backend resource model updates (backend SDK doesn't drive end-user flows)
  • Non-blocking protect_check support — the spec (§2.2.2) defines a forward-compatible non-blocking case where protectCheck !== null but status is the underlying value; the server doesn't emit it today and this PR treats every gate as blocking. Adding non-blocking support is additive when the server starts emitting it.

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 16, 2026

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

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Apr 16, 2026 7:16pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 16, 2026

🦋 Changeset detected

Latest commit: 0f726f7

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

This PR includes changesets to release 21 packages
Name Type
@clerk/clerk-js Minor
@clerk/localizations Minor
@clerk/shared Minor
@clerk/ui Minor
@clerk/chrome-extension Patch
@clerk/expo Patch
@clerk/react Patch
@clerk/agent-toolkit Patch
@clerk/astro Patch
@clerk/backend Patch
@clerk/expo-passkeys Patch
@clerk/express Patch
@clerk/fastify Patch
@clerk/hono Patch
@clerk/msw Patch
@clerk/nextjs Patch
@clerk/nuxt Patch
@clerk/react-router Patch
@clerk/tanstack-react-start Patch
@clerk/testing Patch
@clerk/vue 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
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.

1 participant