Skip to content

feat(clerk-js,shared,react): supply a Protect assertion from the application - #9313

Open
zourzouvillys wants to merge 1 commit into
mainfrom
theo/protect-assertion-sdk
Open

feat(clerk-js,shared,react): supply a Protect assertion from the application#9313
zourzouvillys wants to merge 1 commit into
mainfrom
theo/protect-assertion-sdk

Conversation

@zourzouvillys

Copy link
Copy Markdown
Contributor

Description

A Protect assertion is a short-lived, signed token an application mints from its own backend with the Clerk Backend API, carrying key/value pairs its Protect rules can read.

Until now the only way to deliver one to Protect was the __clerk_protect_assertion cookie. That requires the app and the Frontend API to be same-site — true with a production CNAME setup, but not on development instances, where it is cross-site. It also means an application that has just minted a token has to work out a cookie domain scope in order to hand it over.

The token can now be given to Clerk directly:

// A token you already have.
Clerk.load({ protectAssertion: token });

// A function, re-read for each request.
Clerk.load({ protectAssertion: () => sessionStorage.getItem('protect_assertion') ?? undefined });

// Or set it later, once your app has fetched one.
clerk.setProtectAssertion(token);

Clerk attaches it to sign-in and sign-up requests. The cookie continues to work unchanged; where both are present, the value given to the SDK wins.

Why a function as well as a string

Assertions are short-lived by design and a single-page app routinely outlives one. A string captured at configuration time silently stops applying at expiry; a function is re-read per request, so a token refreshed in the background takes effect without re-configuring Clerk.

Why a body param and not a header

fapiClient already tunnels PATCH/DELETE through a _method query param to keep requests CORS-simple, because the preflight otherwise breaks cookie dropping in Safari (see the existing comments in that file). A custom request header would reintroduce exactly that. The param rides the form-encoded body instead, on the requests Protect gates and nothing else.

The merge has to happen inside request(), before the body is stringified — onBeforeRequest callbacks run after stringification, so a callback cannot add a body param.

Behaviour worth reviewing

Nothing here can fail a sign-in. That is the invariant the tests are built around:

  • a resolver that throws, rejects, or returns anything other than a non-empty string → no assertion, one warning, request proceeds
  • a body that is not a plain object (FormData, a string, a Blob) is left completely alone — spreading it would discard the caller's payload rather than add to it
  • with nothing configured the request is byte-for-byte what it was before, which is why the params resolve to undefined rather than {}

setProtectAssertion tracks that it was called, separately from the value. Without that flag, clearing with undefined would silently fall back to the protectAssertion option, and a call made before Clerk.load() would be overwritten by it.

IsomorphicClerk implements it through the existing premountMethodCalls map, which is keyed by method name — so a second call before load replaces the first, which is the semantics a setter wants.

Checks

  • vitest run src/core in @clerk/clerk-js: 790 passed, including 16 new for the resolver and 12 new for the request wiring
  • @clerk/react: 203 passed. One suite (ClerkProvider.test.tsx) fails to import in my local worktree because @clerk/localizations isn't built there — unrelated to this change
  • build:declarations clean for @clerk/clerk-js; tsc --noEmit clean for @clerk/react
  • eslint clean on every file touched
  • bundlewatch: clerk.native.js gains ~0.2KB gzipped and crossed its 74KB ceiling, so it is raised to 75KB — the same headroom the other entries carry. Everything else passes unchanged.

Note on ordering

This depends on a matching server-side change that must be deployed first — until then the new param is not accepted and would be rejected. Happy to hold this until that has shipped.

There is also an in-flight PR touching fapiClient's request body for a separate Protect feature. Both introduce the same getProtectParams seam; whichever lands first, the other is a trivial resolve (keep one copy of the hook).

…ication

A Protect assertion is a short-lived signed token an application mints from its
own backend, carrying key/value pairs its Protect rules can read. Until now the
only way to deliver one was the __clerk_protect_assertion cookie, which needs
the app and Frontend API to be same-site — true with a production CNAME setup,
not on development instances.

The token can now be handed to Clerk instead, and is attached to sign-in and
sign-up requests:

    Clerk.load({ protectAssertion: token })
    Clerk.load({ protectAssertion: () => readToken() })
    clerk.setProtectAssertion(token)

A function is supported alongside a string because assertions are short-lived by
design and a page routinely outlives one. A string captured at load time stops
applying at expiry; a function is re-read per request, so a token refreshed in
the background takes effect without re-configuring Clerk.

It travels as a form param, not a header. A custom header would trigger a CORS
preflight — the same thing fapiClient already avoids by tunnelling PATCH/DELETE
through a _method query param. The merge has to happen in request(), before the
body is stringified: onBeforeRequest runs after stringification, so a callback
cannot add a body param.

Nothing here can fail a sign-in, which is the invariant the tests are built
around. A resolver that throws, rejects, or returns anything other than a
non-empty string yields no assertion and a warning, and the request proceeds. A
body that is not a plain object is left alone rather than spread — spreading a
FormData would discard the caller's payload instead of adding to it. With no
assertion configured the request is byte-for-byte what it was before, which is
why the params resolve to undefined rather than {}.

setProtectAssertion tracks whether it has been called, separately from the value
it was given. Without that, clearing with undefined would silently fall back to
the option, and a call before load() would be overwritten by it.

IsomorphicClerk implements it through the existing premountMethodCalls map,
which is keyed by method name — so a second call before load replaces the first,
which is the semantics a setter wants.

Bundlewatch: clerk.native.js gains ~0.2KB gzipped and crossed its 74KB ceiling;
raised to 75KB, matching the headroom the other entries carry.

The cookie path is unchanged. Where both are present, the SDK value wins.

Requires the matching server change to be deployed first.
@changeset-bot

changeset-bot Bot commented Aug 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 93b1b36

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/shared Minor
@clerk/react Minor
@clerk/chrome-extension Patch
@clerk/electron Patch
@clerk/expo Patch
@clerk/astro Patch
@clerk/backend Patch
@clerk/expo-passkeys Patch
@clerk/express Patch
@clerk/fastify Patch
@clerk/headless Patch
@clerk/hono Patch
@clerk/localizations Patch
@clerk/msw Patch
@clerk/nextjs Patch
@clerk/nuxt Patch
@clerk/react-router Patch
@clerk/tanstack-react-start Patch
@clerk/testing Patch
@clerk/ui 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

@vercel

vercel Bot commented Aug 1, 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 Aug 1, 2026 1:16am
swingset Ready Ready Preview Aug 1, 2026 1:16am

Request Review

@pkg-pr-new

pkg-pr-new Bot commented Aug 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@9313

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@9313

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@9313

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@9313

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9313

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9313

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9313

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@9313

@clerk/expo-google-signin

npm i https://pkg.pr.new/@clerk/expo-google-signin@9313

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@9313

@clerk/express

npm i https://pkg.pr.new/@clerk/express@9313

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@9313

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@9313

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@9313

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@9313

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@9313

@clerk/react

npm i https://pkg.pr.new/@clerk/react@9313

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@9313

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@9313

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@9313

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@9313

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@9313

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@9313

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@9313

commit: 93b1b36

@zourzouvillys

Copy link
Copy Markdown
Contributor Author

On the two nextjs integration failures

First run: 145 passed, 1 failed, 1 flaky, 2 skipped. Both affected specs are session-task navigation (session-tasks-multi-session, session-tasks-sign-in-reset-password) — Playwright itself classified the second as flaky in that same run. I've re-run the failed jobs rather than assert a cause either way.

Worth stating the one way this change could plausibly have touched them, and why it can't:

request() now awaits the Protect params before building the URL, which adds a microtask on gated requests. The only value read after that point is options.getSessionId(), used to append _clerk_session_id — and that is skipped for any path starting with /client:

const unauthorizedPathPrefixes = ['/client', '/waitlist'];
// ...
if (path && !unauthorizedPathPrefixes.some(p => path.startsWith(p)) && sessionId) {
  searchParams.append('_clerk_session_id', sessionId);
}

Both gated prefixes (/client/sign_ins, /client/sign_ups) start with /client, so the one thing read after the new await is unused on precisely the paths that have it. The added microtask cannot change the request that goes out.

Beyond those paths the hook never runs: isProtectGatedRequest returns false for GET and for any non-matching path, so every other request takes the identical code path it did before.

@zourzouvillys
zourzouvillys marked this pull request as ready for review August 3, 2026 19:05
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-08-03T19:08:55.982Z

Summary

Metric Count
Packages analyzed 19
Packages with changes 2
🔴 Breaking changes 0
🟡 Non-breaking changes 1
🟢 Additions 5

🤖 This report was reviewed by claude-sonnet-4-6.


@clerk/clerk-js

Current version: 6.25.13
Recommended bump: MINOR → 6.26.0

Subpath .

🟢 Additions (1)

Added: Clerk.setProtectAssertion
+ setProtectAssertion: (assertion?: ProtectAssertion) => void;

Added property Clerk.setProtectAssertion

Subpath ./no-rhc

🟢 Additions (1)

Added: Clerk.setProtectAssertion
+ setProtectAssertion: (assertion?: ProtectAssertion) => void;

Added property Clerk.setProtectAssertion


@clerk/shared

Current version: 4.25.10
Recommended bump: MINOR → 4.26.0

Subpath ./types

🟡 Non-breaking Changes (1)

Modified: ClerkOptions
// ... 3 unchanged lines elided ...
    };
    appearance?: any;
    localization?: LocalizationResource;
+   protectAssertion?: ProtectAssertion;
    polling?: boolean;
    selectInitialSession?: (client: ClientResource) => SignedInSessionResource | null;
    standardBrowser?: boolean;
// ... 27 unchanged lines elided ...

Static analyzer: Breaking change in type alias ClerkOptions: Type changed: import("@clerk/shared").AfterMultiSessionSingleSignOutUrl&import("@clerk/shared").AfterSignOutUrl&import("@clerk/shared…import("@clerk/shared").AfterMultiSessionSingleSignOutUrl&import("@clerk/shared").AfterSignOutUrl&import("@clerk/shared…

🤖 AI review (reclassified as non-breaking) (95%): The only change is the addition of a new optional property protectAssertion? to ClerkOptions, which is used as an input type (parameter). Adding an optional field to an input type does not break existing callers — they neither need to pass it nor rely on its absence.

🟢 Additions (3)

Added: Clerk.setProtectAssertion
+ setProtectAssertion: (assertion?: ProtectAssertion) => void;

Added property Clerk.setProtectAssertion

Added: ProtectAssertion
+ type ProtectAssertion = string | ProtectAssertionResolver;

Added type alias ProtectAssertion

Added: ProtectAssertionResolver
+ type ProtectAssertionResolver = () => string | undefined | Promise<string | undefined>;

Added type alias ProtectAssertionResolver


Report generated by Break Check

Last ran on 93b1b36.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds public Protect assertion types and configuration for Clerk and React. Adds runtime setter behavior with explicit clearing and resolver precedence. Resolves static or per-request assertions, validates values, and omits failures. Adds Protect parameters to eligible sign-in and sign-up POST bodies while preserving unsupported body types and unrelated requests. Adds tests, a changeset, and a bundle size threshold update.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • clerk/javascript#9299: Both changes inject Protect parameters into eligible sign-in and sign-up requests and update related fapiClient tests.
  • clerk/javascript#9256: Both changes update Bundlewatch thresholds in packages/clerk-js/bundlewatch.config.json.

Suggested reviewers: jacekradko, nikosdouvlis, dstaley

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: application-supplied Protect assertions across Clerk JS, shared, and React packages.
Description check ✅ Passed The description directly explains Protect assertion support, request behavior, failure handling, integrations, tests, and deployment dependencies.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

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

🧹 Nitpick comments (1)
packages/react/src/isomorphicClerk.ts (1)

393-402: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add tests for setProtectAssertion. Cover immediate delegation after load and replay of a queued call before load. No such cases exist in the React isomorphicClerk tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react/src/isomorphicClerk.ts` around lines 393 - 402, Add React
isomorphicClerk tests for setProtectAssertion, covering immediate delegation
when clerkjs is loaded and replay of a call queued before loading. Verify the
assertion reaches clerkjs in both paths and that the pre-load call is replayed
after initialization.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/react/src/isomorphicClerk.ts`:
- Around line 393-402: Add React isomorphicClerk tests for setProtectAssertion,
covering immediate delegation when clerkjs is loaded and replay of a call queued
before loading. Verify the assertion reaches clerkjs in both paths and that the
pre-load call is replayed after initialization.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: d207710a-c8e2-4475-8120-03779f6a58a0

📥 Commits

Reviewing files that changed from the base of the PR and between fdadaa2 and 93b1b36.

📒 Files selected for processing (10)
  • .changeset/protect-assertion-sdk-option.md
  • packages/clerk-js/bundlewatch.config.json
  • packages/clerk-js/src/core/__tests__/fapiClient.test.ts
  • packages/clerk-js/src/core/__tests__/protectAssertion.test.ts
  • packages/clerk-js/src/core/clerk.ts
  • packages/clerk-js/src/core/fapiClient.ts
  • packages/clerk-js/src/core/protectAssertion.ts
  • packages/react/src/isomorphicClerk.ts
  • packages/shared/src/types/clerk.ts
  • packages/shared/src/types/protectConfig.ts
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • clerk/clerk_go (manual)
  • clerk/dashboard (manual)
  • clerk/accounts (manual)
  • clerk/backoffice (manual)
  • clerk/clerk (manual)
  • clerk/clerk-docs (manual)
  • clerk/cloudflare-workers (manual)
  • clerk/clerk-ios (auto-detected)
  • clerk/clerk-android (auto-detected)
  • clerk/cli (auto-detected)

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