Skip to content

feat(js): attach an optional server-configured session token to sign-in - #9299

Merged
zourzouvillys merged 14 commits into
mainfrom
theo/js-session-token-clean
Aug 12, 2026
Merged

feat(js): attach an optional server-configured session token to sign-in#9299
zourzouvillys merged 14 commits into
mainfrom
theo/js-session-token-clean

Conversation

@zourzouvillys

@zourzouvillys zourzouvillys commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Description

Adds an optional, server-configured session token to @clerk/clerk-js, attached to sign-in and sign-up requests.

It is inert unless an instance's loader configuration opts into it. An instance not using it keeps today's behaviour exactly, stores nothing in the browser, and sends no additional parameters.

  • Placeholder interpolation in loader config. A closed set — {cid}, {pid}, {rid}, {instance_id}, {sdkver} — substituted into a loader's attribute values and textContent before the element is appended. An unrecognised {…} is left verbatim, so an older SDK loading the same configuration stays compatible.
  • An opaque, random correlation id, minted with crypto.getRandomValues as lowercase unpadded RFC 4648 base32. Nothing is derived from the device — no fingerprinting input, no clock, no user data.
  • One acquisition per browser session, shared across tabs. Runs under the existing SafeLock primitive (core/auth/safeLock.ts) that SessionCookiePoller already uses, with a re-check inside the lock so concurrent tabs do not each acquire in turn. A wedged leader delays nobody, and a leader tab closing mid-run releases its lock automatically.
  • Prefetched at Clerk.load() rather than at sign-in, so acquisition stays off the latency-critical path. It is bounded by a deadline and can never block or fail a sign-in: when no token is obtained, a status travels in its place and the request proceeds unchanged.
  • Parameters ride in the form-encoded body of sign-in and sign-up POSTs. Not the query string, since a signed credential should not land in access logs along the path; and not a header, since a custom header reintroduces the CORS preflight that breaks cookie dropping in Safari — the same constraint that already forces _method into the query. No request header is added anywhere. The merge is an explicit step in fapiClient.request on a path allowlist, because the onBeforeRequest callbacks fire after the body is stringified.

ProtectLoader gains two optional fields, tokenUrl and tokenTimeoutMs, both inert unless set.

rollout moved out of applyLoader into a module-level helper so load() can decide participation before it starts applying loaders. Same semantics, evaluated once.

Bundle thresholds were bumped on the affected bundles to cover the addition.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated — the persistent pseudonymous identifier needs a mention in customer-facing privacy docs; tracked separately.

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

Mints an opaque, random correlation id and acquires a signed Protect
session token once per browser session, shared across tabs under a lock.
The token, the correlation id and an acquisition status travel in the
form-encoded body of sign-in and sign-up POSTs.

Acquisition never blocks a sign-in: on timeout, script-load failure or a
non-2xx response a structured status travels in the token's place and the
request proceeds. Instances whose loader config does not reference the new
placeholders keep today's behaviour and store nothing in the browser.
@changeset-bot

changeset-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8cace84

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/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/react 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 Jul 31, 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 12, 2026 12:31am
swingset Ready Ready Preview Aug 12, 2026 12:31am

Request Review

@pkg-pr-new

pkg-pr-new Bot commented Jul 31, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

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

@clerk/electron-passkeys

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

@clerk/eslint-plugin

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

@clerk/expo

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

@clerk/expo-google-signin

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

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: 8cace84

Follows the EDR-0020 amendment (clerk/protect#408): the session token now
ships inline on the loader's own global rather than behind a second request,
so the gate costs no extra round trip on the auth critical path. `tokenUrl`
becomes an explicit opt-in for the upgrade mint, and is the only path that
can report `fetch_error` or an HTTP status.

That removes the derive-the-endpoint-from-an-attribute fallback, which
resolved against `document.baseURI` (pointing polling at the app's own
origin) and stripped a trailing `{cid}` via relative resolution.

The session now owns the token loader: it injects it under the acquisition
lock and reads the token when the element fires `load`. Every other loader
is applied on every page load, rather than all loaders being suppressed
whenever a token happened to be cached.

Also fixed:

- A malformed loader entry no longer escapes `Protect.load()` and fails
  `Clerk.load()` for every visitor.
- `readStored` falls back to the in-memory store, so a token written there
  under an exhausted quota is no longer read back as absent.
- `getRequestParams` is bounded by the acquisition deadline, and a
  server-supplied `tokenTimeoutMs` is capped, so a sign-in cannot be stalled
  before dispatch. It can no longer reject into the request either.
- A settled, tokenless acquisition re-arms after a cooldown instead of being
  replayed for the life of the tab.
- The token store and lock are namespaced per instance, so two instances on
  one origin no longer share a token.
- A stored token is validated against a maximum lifetime and length, so a
  planted entry cannot suppress acquisition indefinitely.
- The upgrade mint retries a transient 408/429/5xx within its deadline.
- `{instance_id}` alone no longer mints and persists a client id.
- `textContent` placeholders are detected and substituted.
- `isMergeableBody` admits only plain objects, so a Blob or array body is no
  longer spread away.
Aligns with clerk/protect: the loader global carries `ready`, a promise
resolving to `{token, exp}` or `{status: "no_token"}`, rather than the
token directly. It is the loader's completion signal and the seam where
page-side probes and the EDR-0020 proof-of-possession challenge will
live, so the token is awaited (raced against the acquisition deadline)
instead of read synchronously.

`{sdkver}` joins the placeholder set, substituted with the build version.
Its presence is what tells the server this build interpolates at all, and
so can be served the current shape; `/v1/environment` is cached and
cannot vary by SDK version, so the negotiation has to happen per request.
A build that leaves it verbatim is served the base `{v, id}` shape.

`no_token` joins the status set, matching Warden's allowlist. It is what
the base shape, a load with no correlation id, and a failed mint all
report. Reporting `timeout` for those would mark every load between this
shipping and the server half deploying as a failure, making a normal
rollout look like an outage.

A `ready` that rejects is treated as nothing served rather than
propagated; the contract says it never rejects, but an unhandled
rejection on every page load is not worth taking on trust.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-08-12T00:34:03.382Z

Summary

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

Warning
1 breaking change(s) detected - Major version bump required

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

🔴 Breaking changes index (1)

Every breaking change, up front. Full diffs are in the package sections below.

Package Subpath Change
@clerk/shared ./types ProtectLoader.textContent

@clerk/shared

Current version: 4.28.1
Recommended bump: MAJOR → 5.0.0

Subpath ./types

🔴 Breaking Changes (1)

Changed: ProtectLoader.textContent
- textContent?: string;

Static analyzer: Removed property ProtectLoader.textContent

🤖 AI review (confirmed) (85%): Removing an optional property from a publicly exported interface is breaking for consumers who read or destructure textContent from a ProtectLoader value, as the property no longer exists on the type.

Migration: Remove any references to textContent from ProtectLoader usage, or guard access with an existence check if a runtime value may still carry it.

🟡 Non-breaking Changes (1)

Modified: ProtectConfigResource.id
- id: string;
+ id?: string;

Static analyzer: Modified property ProtectConfigResource.id: Member became optional

🤖 AI review (confirmed) (90%): Making an existing required property optional on an interface is non-breaking for consumers who read it (output position) and for consumers who construct it (they already provided the value, and now they are merely allowed to omit it).

🟢 Additions (5)

Added: ProtectConfigJSON.tokens_invalid_before
+ tokens_invalid_before?: number;

Added property ProtectConfigJSON.tokens_invalid_before

Added: ProtectConfigResource.tokens_invalid_before
+ tokens_invalid_before?: number;

Added property ProtectConfigResource.tokens_invalid_before

Added: ProtectLoader.text_content
+ text_content?: string;

Added property ProtectLoader.text_content

Added: ProtectLoader.token_timeout_ms
+ token_timeout_ms?: number;

Added property ProtectLoader.token_timeout_ms

Added: ProtectLoader.token_url
+ token_url?: string;

Added property ProtectLoader.token_url


Report generated by Break Check

Last ran on 8cace84.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Added ProtectSession for token-loader and upgrade-endpoint acquisition, shared storage, locking, retries, deadlines, cooldowns, and status reporting. Integrated session placeholders into Protect loaders and combined session-token and assertion parameters for eligible sign-in and sign-up requests. Added configuration types, tests, a changeset, and updated bundle limits.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers: wobsoriano

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% 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 feature: attaching an optional server-configured session token to sign-in requests.
Description check ✅ Passed The description directly explains the session-token feature, its configuration, request integration, and behavior for unaffected instances.
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.

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.

Actionable comments posted: 1

🧹 Nitpick comments (11)
packages/clerk-js/bundlewatch.config.json (1)

3-7: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Reduce the Bundlewatch ceilings. Set them to 550KB, 77KB, 119KB, 317KB, and 77KB for the five bundles. The current values add unnecessary headroom.

🤖 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/clerk-js/bundlewatch.config.json` around lines 3 - 7, Update the
Bundlewatch maxSize values for the five bundle entries in order: clerk.js to
550KB, clerk.browser.js to 77KB, clerk.legacy.browser.js to 119KB,
clerk.no-rhc.js to 317KB, and clerk.native.js to 77KB.
packages/shared/src/types/protectConfig.ts (1)

23-27: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document the upper bound for tokenTimeoutMs.

clampTimeout in packages/clerk-js/src/core/protectSession.ts limits this value to 10000 ms and falls back to 5000 for non-positive or non-finite values. The doc comment states only the default. State the ceiling so instance operators know that a larger configured value has no effect.

📝 Proposed doc update
   /**
    * How long to wait for the token before giving up and reporting a status instead. Defaults to
-   * 5000.
+   * 5000, and is capped at 10000 by the SDK.
    */
   tokenTimeoutMs?: number;
🤖 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/shared/src/types/protectConfig.ts` around lines 23 - 27, Update the
documentation for tokenTimeoutMs in the protect configuration type to state that
values are capped at 10000 ms, while retaining the existing 5000 ms default
description.
packages/clerk-js/src/core/protectSession.ts (2)

419-425: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add an explicit return type to ProtectSession.create.

create is a public static factory on an exported class. Its return type is inferred as ProtectSession | undefined. Declare it so the public surface stays stable if the body changes.

🔧 Proposed fix
-  static create(loaders: ProtectLoader[], instanceId: string | undefined, applyLoader: ApplyLoader) {
+  static create(
+    loaders: ProtectLoader[],
+    instanceId: string | undefined,
+    applyLoader: ApplyLoader,
+  ): ProtectSession | undefined {

As per coding guidelines: "Always define explicit return types for functions, especially public APIs". Based on learnings: enforce explicit return type annotations for exported functions and public APIs.

🤖 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/clerk-js/src/core/protectSession.ts` around lines 419 - 425, Update
the public static factory method ProtectSession.create with an explicit return
type annotation of ProtectSession | undefined, preserving its existing undefined
result for empty templated loaders and ProtectSession result otherwise.

Sources: Coding guidelines, Learnings


5-20: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reduce the volume of narrative comments.

The file carries many multi-line explanatory blocks. Several restate what the code already shows, for example lines 214-218 and 601-604. Keep the design rationale that is not obvious from the code, such as why no_token is distinct from timeout, and shorten the rest to one line each. Consider moving the long module-level narrative to a design document.

As per coding guidelines: "Keep code comments minimal. Add comments only when critical to explain why a non-obvious change was made; never restate code behavior, and keep warranted comments to one terse line rather than a verbose multi-line block."

Also applies to: 103-108, 153-156, 214-218, 245-248, 290-299, 506-509, 518-521, 601-604, 687-691

🤖 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/clerk-js/src/core/protectSession.ts` around lines 5 - 20, Reduce
comments throughout protectSession.ts, especially the module-level block and the
listed ranges, to terse single-line comments or remove them when they merely
restate the surrounding code. Preserve only non-obvious design rationale,
including why no_token differs from timeout, and retain concise comments only
where they explain that rationale or another critical implementation decision.

Source: Coding guidelines

packages/clerk-js/src/core/fapiClient.ts (2)

236-244: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Bound the getProtectParams await inside fapiClient.

This await sits on the sign-in and sign-up critical path. The current producer, Protect.getRequestParams, bounds itself with an acquisition deadline of at most 10 s and never rejects, so the request is not blocked indefinitely today. getProtectParams is a public option on FapiClientOptions, so any other supplier can hang the request forever with no recovery.

Add a local deadline so fapiClient does not depend on the caller for that guarantee.

🛡️ Proposed defensive bound
+const PROTECT_PARAMS_TIMEOUT_MS = 10_000;
+
+function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
+  return new Promise(resolve => {
+    const timer = setTimeout(() => resolve(undefined), ms);
+    const settle = (value: T | undefined) => {
+      clearTimeout(timer);
+      resolve(value);
+    };
+    promise.then(settle, () => settle(undefined));
+  });
+}
-      const protectParams = await options.getProtectParams().catch(() => undefined);
+      const protectParams = await withTimeout(options.getProtectParams(), PROTECT_PARAMS_TIMEOUT_MS);
🤖 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/clerk-js/src/core/fapiClient.ts` around lines 236 - 244, Bound the
getProtectParams() await in the fapiClient request flow with a local timeout so
a hanging public option cannot block sign-in or sign-up indefinitely. Preserve
the existing fallback behavior by treating timeout or supplier rejection as
undefined and continuing without protect parameters.

240-243: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the redundant assignment and cast.

isMergeableBody narrows body to Record<string, unknown> | undefined inside this block, so the as Record<string, unknown> cast adds nothing. Line 242 is also redundant, because lines 246-248 reassign requestInit.body for every plain-object body.

♻️ Proposed simplification
       const protectParams = await options.getProtectParams().catch(() => undefined);
       if (protectParams) {
-        body = { ...((body ?? {}) as Record<string, unknown>), ...protectParams } as unknown as BodyInit;
-        requestInit.body = body;
+        body = { ...(body ?? {}), ...protectParams } as unknown as BodyInit;
       }
🤖 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/clerk-js/src/core/fapiClient.ts` around lines 240 - 243, In the
protectParams handling near isMergeableBody, remove the redundant Record<string,
unknown> cast and the immediate requestInit.body assignment; retain only the
merged body assignment, since the later plain-object body flow already updates
requestInit.body.
packages/clerk-js/src/core/protect.ts (1)

38-41: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Type #apply input as unknown[].

config.loaders comes from the server response and is not validated before this call. The tests pass null, 'nope' and 42 in that array. The parameter type ProtectLoader[] states a guarantee that does not hold, and isLoader is the guard that establishes it. Declare the input as unknown[] so the narrowing performed by isLoader is visible in the types.

🔧 Proposed change
-  `#apply`(configured: ProtectLoader[], instanceId?: string): void {
+  `#apply`(configured: unknown[], instanceId?: string): void {
     // Rollout is decided before anything else, because the session is only meaningful for the
     // loaders we are actually going to apply.
     const loaders = configured.filter(loader => isLoader(loader) && inRollout(loader));

isLoader already returns loader is ProtectLoader, so loaders still narrows to ProtectLoader[].

🤖 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/clerk-js/src/core/protect.ts` around lines 38 - 41, Update the
`#apply` method parameter from ProtectLoader[] to unknown[] to reflect that
server-provided loader values are unvalidated. Preserve the existing
configured.filter call so isLoader narrows valid entries to ProtectLoader[]
before rollout processing.
packages/clerk-js/src/core/__tests__/protectSession.test.ts (3)

370-384: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a case for a stored token that exceeds MAX_TOKEN_LENGTH.

validateToken rejects a token longer than 4096 characters. The suite covers the expiry bound and the lifetime bound, but not the length bound. Add a planted entry with an over-length token and assert that hasFreshToken() returns false.

As per coding guidelines: "Verify proper error handling and edge cases".

🤖 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/clerk-js/src/core/__tests__/protectSession.test.ts` around lines 370
- 384, Add a test case alongside the existing planted-token coverage that stores
a token longer than MAX_TOKEN_LENGTH in localStorage, then initializes a session
and asserts created?.hasFreshToken() is false. Reuse the existing session setup
and token-storage key used in protectSession.test.ts, focusing only on the
over-length validation path.

Source: Coding guidelines


245-255: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Simplify the foreign correlation id.

'z'.repeat(26).replace(/z/g, 'a') produces 'a'.repeat(26). Use the direct form so the intent stays readable.

♻️ Proposed change
-    serveInline(await injected(), { cid: buildCid('z'.repeat(26).replace(/z/g, 'a'), 'b'.repeat(26)) });
+    serveInline(await injected(), { cid: buildCid('a'.repeat(26), 'b'.repeat(26)) });
🤖 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/clerk-js/src/core/__tests__/protectSession.test.ts` around lines 245
- 255, In the test case “ignores a token minted for someone else’s run,”
simplify the foreign correlation ID passed to buildCid by replacing the
redundant 'z'.repeat(26).replace(/z/g, 'a') expression with the direct
equivalent 'a'.repeat(26); leave the test behavior unchanged.

298-305: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

These deadline tests depend on real wall-clock time.

tokenTimeoutMs values of 40, 50 and 60 ms are shorter than a slow CI tick. A stalled event loop can let the loader path settle after the deadline, or let an unrelated status win. The assertion at line 537 also measures elapsed real time.

Raise the deadlines to a value that tolerates scheduler jitter, or drive them with vi.useFakeTimers(). The test at lines 540-556 already mocks Date.now, so fake timers fit the existing style.

Also applies to: 328-334, 531-538

🤖 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/clerk-js/src/core/__tests__/protectSession.test.ts` around lines 298
- 305, Stabilize the deadline tests in the protectSession suite by replacing the
40, 50, and 60 ms real-time tokenTimeoutMs values and elapsed-time assertion
with scheduler-independent timing. Prefer vi.useFakeTimers() while preserving
the existing mocked-Date.now style, and update the tests around the timeout
cases and the assertion near the elapsed-time check so deadlines are advanced
explicitly and timeout remains the winning status.
packages/clerk-js/src/core/__tests__/protect.test.ts (1)

172-179: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add coverage for a token loader whose target does not resolve.

applyLoader now returns undefined when the #id target element is missing, and ProtectSession.#runTokenLoader maps that to script_error. No test drives that branch. Add a case with target: '#missing' on the token loader and assert __clerk_protect_status: 'script_error'.

As per coding guidelines: "Verify proper error handling and edge cases".

🤖 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/clerk-js/src/core/__tests__/protect.test.ts` around lines 172 - 179,
Add a test alongside the existing loader error case that configures the token
loader with target: '`#missing`', invokes the Protect request flow, and asserts it
resolves with __clerk_protect_status set to 'script_error'. Exercise the
missing-target path in ProtectSession.#runTokenLoader rather than dispatching a
script element error.

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.

Inline comments:
In @.changeset/lucky-pandas-observe.md:
- Line 12: Update the release note describing ProtectLoader’s tokenUrl field so
it states that the loader token remains inline and tokenUrl is used only for
optional upgrade minting; retain tokenTimeoutMs and the existing optional-field
context.

---

Nitpick comments:
In `@packages/clerk-js/bundlewatch.config.json`:
- Around line 3-7: Update the Bundlewatch maxSize values for the five bundle
entries in order: clerk.js to 550KB, clerk.browser.js to 77KB,
clerk.legacy.browser.js to 119KB, clerk.no-rhc.js to 317KB, and clerk.native.js
to 77KB.

In `@packages/clerk-js/src/core/__tests__/protect.test.ts`:
- Around line 172-179: Add a test alongside the existing loader error case that
configures the token loader with target: '`#missing`', invokes the Protect request
flow, and asserts it resolves with __clerk_protect_status set to 'script_error'.
Exercise the missing-target path in ProtectSession.#runTokenLoader rather than
dispatching a script element error.

In `@packages/clerk-js/src/core/__tests__/protectSession.test.ts`:
- Around line 370-384: Add a test case alongside the existing planted-token
coverage that stores a token longer than MAX_TOKEN_LENGTH in localStorage, then
initializes a session and asserts created?.hasFreshToken() is false. Reuse the
existing session setup and token-storage key used in protectSession.test.ts,
focusing only on the over-length validation path.
- Around line 245-255: In the test case “ignores a token minted for someone
else’s run,” simplify the foreign correlation ID passed to buildCid by replacing
the redundant 'z'.repeat(26).replace(/z/g, 'a') expression with the direct
equivalent 'a'.repeat(26); leave the test behavior unchanged.
- Around line 298-305: Stabilize the deadline tests in the protectSession suite
by replacing the 40, 50, and 60 ms real-time tokenTimeoutMs values and
elapsed-time assertion with scheduler-independent timing. Prefer
vi.useFakeTimers() while preserving the existing mocked-Date.now style, and
update the tests around the timeout cases and the assertion near the
elapsed-time check so deadlines are advanced explicitly and timeout remains the
winning status.

In `@packages/clerk-js/src/core/fapiClient.ts`:
- Around line 236-244: Bound the getProtectParams() await in the fapiClient
request flow with a local timeout so a hanging public option cannot block
sign-in or sign-up indefinitely. Preserve the existing fallback behavior by
treating timeout or supplier rejection as undefined and continuing without
protect parameters.
- Around line 240-243: In the protectParams handling near isMergeableBody,
remove the redundant Record<string, unknown> cast and the immediate
requestInit.body assignment; retain only the merged body assignment, since the
later plain-object body flow already updates requestInit.body.

In `@packages/clerk-js/src/core/protect.ts`:
- Around line 38-41: Update the `#apply` method parameter from ProtectLoader[] to
unknown[] to reflect that server-provided loader values are unvalidated.
Preserve the existing configured.filter call so isLoader narrows valid entries
to ProtectLoader[] before rollout processing.

In `@packages/clerk-js/src/core/protectSession.ts`:
- Around line 419-425: Update the public static factory method
ProtectSession.create with an explicit return type annotation of ProtectSession
| undefined, preserving its existing undefined result for empty templated
loaders and ProtectSession result otherwise.
- Around line 5-20: Reduce comments throughout protectSession.ts, especially the
module-level block and the listed ranges, to terse single-line comments or
remove them when they merely restate the surrounding code. Preserve only
non-obvious design rationale, including why no_token differs from timeout, and
retain concise comments only where they explain that rationale or another
critical implementation decision.

In `@packages/shared/src/types/protectConfig.ts`:
- Around line 23-27: Update the documentation for tokenTimeoutMs in the protect
configuration type to state that values are capped at 10000 ms, while retaining
the existing 5000 ms default description.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

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

Review profile: CHILL

Plan: Pro Plus

Run ID: 5137fbb9-ce5a-4323-ab93-b45f8ad4be06

📥 Commits

Reviewing files that changed from the base of the PR and between 0ba9dbe and ed13b06.

📒 Files selected for processing (10)
  • .changeset/lucky-pandas-observe.md
  • packages/clerk-js/bundlewatch.config.json
  • packages/clerk-js/src/core/__tests__/fapiClient.test.ts
  • packages/clerk-js/src/core/__tests__/protect.test.ts
  • packages/clerk-js/src/core/__tests__/protectSession.test.ts
  • packages/clerk-js/src/core/clerk.ts
  • packages/clerk-js/src/core/fapiClient.ts
  • packages/clerk-js/src/core/protect.ts
  • packages/clerk-js/src/core/protectSession.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)

Comment thread .changeset/lucky-pandas-observe.md Outdated

@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/clerk-js/src/core/__tests__/protectSession.test.ts (1)

336-384: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add regression coverage for malformed stored token entries. Test invalid JSON, missing or malformed rid, and non-string token; readStoredToken must reject each and start a fresh acquisition without constructing a malformed __clerk_protect_cid.

🤖 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/clerk-js/src/core/__tests__/protectSession.test.ts` around lines 336
- 384, Extend the protectSession tests around stored-token reuse to cover
invalid JSON, missing or malformed rid, and non-string token entries. Verify
readStoredToken rejects each case, hasFreshToken() is false, and the session
starts fresh acquisition through the loader without producing a malformed
__clerk_protect_cid; reuse the existing session, loader, and request-parameter
setup.

Source: Linters/SAST tools

🤖 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/clerk-js/src/core/__tests__/protectSession.test.ts`:
- Around line 336-384: Extend the protectSession tests around stored-token reuse
to cover invalid JSON, missing or malformed rid, and non-string token entries.
Verify readStoredToken rejects each case, hasFreshToken() is false, and the
session starts fresh acquisition through the loader without producing a
malformed __clerk_protect_cid; reuse the existing session, loader, and
request-parameter setup.

ℹ️ Review info
⚙️ Run configuration

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

Review profile: CHILL

Plan: Pro Plus

Run ID: 681154a7-1772-4ec0-9732-fd243673889d

📥 Commits

Reviewing files that changed from the base of the PR and between 0ba9dbe and ed13b06.

📒 Files selected for processing (10)
  • .changeset/lucky-pandas-observe.md
  • packages/clerk-js/bundlewatch.config.json
  • packages/clerk-js/src/core/__tests__/fapiClient.test.ts
  • packages/clerk-js/src/core/__tests__/protect.test.ts
  • packages/clerk-js/src/core/__tests__/protectSession.test.ts
  • packages/clerk-js/src/core/clerk.ts
  • packages/clerk-js/src/core/fapiClient.ts
  • packages/clerk-js/src/core/protect.ts
  • packages/clerk-js/src/core/protectSession.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)
🚧 Files skipped from review as they are similar to previous changes (8)
  • packages/clerk-js/src/core/clerk.ts
  • packages/clerk-js/src/core/fapiClient.ts
  • packages/clerk-js/bundlewatch.config.json
  • .changeset/lucky-pandas-observe.md
  • packages/shared/src/types/protectConfig.ts
  • packages/clerk-js/src/core/tests/fapiClient.test.ts
  • packages/clerk-js/src/core/protect.ts
  • packages/clerk-js/src/core/protectSession.ts

Comment on lines +553 to +556
if (this.#freshStoredToken()) {
// Step 1: valid and not near expiry. No lock, no loader, no network.
return 'ok';
}

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.

[MEDIUM] A page-writable localStorage entry suppresses the Protect loader entirely and makes the client report status: ok

Pre-PR every loader was injected on every page load. Now the {cid} loader is injected only when __clerk_protect_st lacks a fresh token, and #freshStoredToken() accepts an entry that merely satisfies the rid regex, typeof token === 'string' && length <= 4096 (no format check), and an exp within 24h. One localStorage.setItem therefore keeps the detection script out of the DOM — the new test asserts elements.length === 0 on this path — and makes getRequestParams() emit __clerk_protect_status: 'ok'.

Network-blocking the loader yields the distinguishable script_error; this path does not, so it is a strictly quieter suppression. The actionable half is server-side: __clerk_protect_status is client-asserted and unauthenticated, so ok accompanied by a token the fraud service cannot verify should read as adversarial rather than benign.

Suggest binding freshness to a token the client cannot mint (e.g. verify the v1.payload.mac shape before trusting the entry) and treating unverifiable ok as absent server-side.

— Comment generated with Claude with @dominic-clerk's supervision

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.

__clerk_protect_status is telemetry — it exists so we can diagnose why acquisition didn't produce a token (blocked, timed out, unsupported browser), and it's a debugging aid, not a security control. It's client-asserted and treated that way: nothing is granted on the strength of it.

The stored entry likewise gates a cache, not trust. The token is verified server-side, so an entry that doesn't verify reaches the same place as no entry at all — and writing one isn't cheaper than simply sending the same values directly, which was always possible. That's why binding freshness to an unmintable token can't be the mitigation: the client can't do the verification, and the route it would close isn't the cheap one.

Taking the shape check regardless, for a different reason: validateToken documents that intent and doesn't implement it, and a corrupt or truncated entry suppressing acquisition for a real user is worth closing on its own. It'll be version-agnostic so an older SDK doesn't reject a future mint.

Server-side handling of unverifiable tokens is already covered in the paired internal PR.

`validateToken` documented that a value which could not have come from a mint of
ours is discarded, but only checked type, length and expiry — so a corrupt or
truncated store entry counted as fresh and suppressed acquisition until it
expired, up to the lifetime ceiling.

This is hygiene, not a security boundary, and is deliberately not framed as one:
only the backend can tell a real token from a well-formed forgery, and anything
that can write the store can send the same values to the API directly. What it
buys is that a broken entry starts a fresh run immediately.

The shape is matched version-agnostically. Pinning it to the current version
would mean an SDK rejecting a token the backend had minted ahead of it, and
re-running the loader on every page load until the SDK caught up — a test guards
against that tightening.
Conflict was bundlewatch.config.json only. main and this branch agreed on four
of the five budgets; they diverged on clerk.native.js, which main raised 74->76
and this branch raised 74->77. Both increases are real and independent, so the
resolution is additive: 79KB.
main's #9313 added the application-supplied Protect assertion through the same
`getProtectParams` seam this branch added for the server-configured session
token, so every conflict was on shared ground.

* clerk.ts — both sides defined `getProtectParams`, each returning only its own
  feature's params. Taking either side compiles, type-checks and silently stops
  sending the other's. The two write disjoint params, so the hook now unions
  them via `#protectParams`: resolved concurrently, a failure on one side costs
  only that side's params, and `undefined` when neither contributes so a request
  from an instance using no Protect feature is unchanged.

* fapiClient.ts — the merge plumbing had already landed identically on both
  sides; only the comments differed. Kept the wording that is feature-neutral,
  since the seam now carries two features rather than the session token alone.

* fapiClient.test.ts — both sides wrote a describe block for the same plumbing
  from their own feature's angle. Merged into one, keeping every distinct
  assertion from each: the extra sign-up attempt path and the string-body and
  param-name-mangling cases from main, and the no-extra-headers, not-in-URL,
  hook-not-called and non-plain-body cases from here. The fixture now carries
  both features' params.

* bundlewatch.config.json — neither side's budget is right after the merge: the
  two branches' size increases are additive, not alternative, so the per-file
  max still failed three bundles. Regenerated from a real build via
  `pnpm bundlewatch:fix`; bundlewatch passes.

Verified: clerk-js 1036 tests pass, shared 1207 pass, eslint clean on the
resolved files (6 pre-existing warnings elsewhere), prettier clean, bundlewatch
PASS.
…tures

The assertion and the session token feed one hook from one expression, and
nothing covered that wiring — protectAssertion.test.ts and protect{,Session}
.test.ts each cover their own side, fapiClient.test.ts covers merging whatever
the hook returns into the body, but the gatherer between them was untested.
Dropping either source from it compiles, type-checks and passes every existing
test, while that feature's params silently stop being sent.

Checked by mutation: removing the session source fails 3 of these tests,
removing the assertion source fails 3, and dropping the `undefined` guard fails
1. Needs no environment harness — `#protect` is built in the constructor, so
mocking ../protect and capturing the options handed to createFapiClient is
enough.

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/clerk-js/src/core/clerk.ts (1)

485-488: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Document setProtectAssertion as a public API.

Line 485 adds a public Clerk method without JSDoc. Document the accepted assertion forms, the undefined clearing behavior, and its effect on later sign-in and sign-up requests.

As per coding guidelines, “All public APIs must be documented with JSDoc.”

🤖 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/clerk-js/src/core/clerk.ts` around lines 485 - 488, Add JSDoc to the
public setProtectAssertion method describing accepted ProtectAssertion forms,
that passing undefined clears the assertion, and that the configured value
affects subsequent sign-in and sign-up requests.

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.

Inline comments:
In `@packages/clerk-js/src/core/__tests__/clerk.protect-params.test.ts`:
- Around line 50-101: Add a test covering explicit assertion clearing:
initialize Clerk with protectAssertion, call setProtectAssertion(undefined),
then invoke the getProtectParams hook and assert the cleared assertion is
absent. Ensure the test distinguishes the cleared runtime value from any
fallback to this.#options.protectAssertion.
- Around line 23-31: Replace the `any` annotations in the hoisted
`capturedOptions` state and the mocked `createFapiClient` parameter with
`Parameters<typeof createFapiClient>[0]`, importing or referencing
`createFapiClient` as needed. Preserve the existing capture-and-delegate
behavior while ensuring the test tracks contract changes.

---

Outside diff comments:
In `@packages/clerk-js/src/core/clerk.ts`:
- Around line 485-488: Add JSDoc to the public setProtectAssertion method
describing accepted ProtectAssertion forms, that passing undefined clears the
assertion, and that the configured value affects subsequent sign-in and sign-up
requests.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

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

Review profile: CHILL

Plan: Pro Plus

Run ID: b26c4276-4a48-43a2-af4a-dbededd678ea

📥 Commits

Reviewing files that changed from the base of the PR and between 2cd115a and 1151f46.

📒 Files selected for processing (6)
  • packages/clerk-js/bundlewatch.config.json
  • packages/clerk-js/src/core/__tests__/clerk.protect-params.test.ts
  • packages/clerk-js/src/core/__tests__/fapiClient.test.ts
  • packages/clerk-js/src/core/clerk.ts
  • packages/clerk-js/src/core/fapiClient.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/cli (auto-detected)
  • clerk/clerk-ios (auto-detected)
  • clerk/clerk-android (auto-detected)
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/shared/src/types/protectConfig.ts
  • packages/clerk-js/src/core/tests/fapiClient.test.ts
  • packages/clerk-js/bundlewatch.config.json

Comment on lines +23 to +31
const { capturedOptions } = vi.hoisted(() => ({ capturedOptions: { current: undefined as any } }));

vi.mock('../fapiClient', async importOriginal => {
const actual = await importOriginal<typeof import('../fapiClient')>();
return {
...actual,
createFapiClient: (options: any) => {
capturedOptions.current = options;
return actual.createFapiClient(options);

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Type the FAPI mock options.

Lines 23 and 29 use any, so a createFapiClient contract change can bypass this integration test. Derive the captured value and mock parameter from Parameters<typeof createFapiClient>[0].

As per coding guidelines, “Avoid any type” and “No any types without justification in code review.”

🤖 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/clerk-js/src/core/__tests__/clerk.protect-params.test.ts` around
lines 23 - 31, Replace the `any` annotations in the hoisted `capturedOptions`
state and the mocked `createFapiClient` parameter with `Parameters<typeof
createFapiClient>[0]`, importing or referencing `createFapiClient` as needed.
Preserve the existing capture-and-delegate behavior while ensuring the test
tracks contract changes.

Source: Coding guidelines

Comment on lines +50 to +101
describe('Clerk getProtectParams', () => {
beforeEach(() => {
getRequestParams.mockReset();
capturedOptions.current = undefined;
});

it('is wired into the FAPI client', () => {
expect(hookFor()).toBeTypeOf('function');
});

it('unions the assertion and the session token', async () => {
getRequestParams.mockResolvedValue(sessionParams);

await expect(hookFor('token-abc')()).resolves.toEqual({ ...assertionParams, ...sessionParams });
});

it('sends the session token when no assertion is configured', async () => {
getRequestParams.mockResolvedValue(sessionParams);

await expect(hookFor()()).resolves.toEqual(sessionParams);
});

it('sends the assertion when the session contributes nothing', async () => {
getRequestParams.mockResolvedValue(undefined);

await expect(hookFor('token-abc')()).resolves.toEqual(assertionParams);
});

// Returning `{}` would make every sign-in body differ from what it was before the feature existed.
it('resolves to undefined when neither contributes anything', async () => {
getRequestParams.mockResolvedValue(undefined);

await expect(hookFor()()).resolves.toBeUndefined();
});

// Neither feature may take the other down with it.
it('keeps the session token when the assertion resolver throws', async () => {
getRequestParams.mockResolvedValue(sessionParams);

await expect(
hookFor(() => {
throw new Error('boom');
})(),
).resolves.toEqual(sessionParams);
});

it('keeps the assertion when acquiring the session token rejects', async () => {
getRequestParams.mockRejectedValue(new DOMException('storage is blocked', 'SecurityError'));

await expect(hookFor('token-abc')()).resolves.toEqual(assertionParams);
});
});

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test explicit assertion clearing.

Add a test that loads Clerk with protectAssertion, then calls setProtectAssertion(undefined). Assert that getProtectParams no longer returns the option assertion. The current tests cannot detect a regression where clearing falls back to this.#options.protectAssertion.

As per coding guidelines, “Unit tests are required for all new functionality” and tests must verify edge cases.

🤖 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/clerk-js/src/core/__tests__/clerk.protect-params.test.ts` around
lines 50 - 101, Add a test covering explicit assertion clearing: initialize
Clerk with protectAssertion, call setProtectAssertion(undefined), then invoke
the getProtectParams hook and assert the cleared assertion is absent. Ensure the
test distinguishes the cleared runtime value from any fallback to
this.#options.protectAssertion.

Source: Coding guidelines

@Ephem Ephem 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 don't have a ton of context on the protect stuff and this is a lot to wrap ones head around, but I think I've managed to form a decent mental model.

I wont pretend I understand all the details here, but I've focused especially on understanding and verifying the mechanisms that makes sure existing clients aren't affected, and that seems 👌. Also tried it in our dashboard, no issues.

The approach seems good in general too, I see no obvious blockers. There's a bit of overlap with how we refresh session tokens which has been a thorny area historically, but this thankfully has a bit less complexity.

Comment thread .changeset/lucky-pandas-observe.md Outdated
Comment on lines +6 to +12
Acquire an optional Protect session token and attach it to sign-in and sign-up requests.

On instances whose loader config references the new `{cid}` / `{pid}` / `{rid}` / `{instance_id}` / `{sdkver}` placeholders, Clerk mints an opaque, random correlation id and substitutes it into the loader's attributes and `textContent`. The loader is served with a signed session token, which is taken up once per browser session — shared across tabs under a lock rather than acquired once per tab — and travels alongside the correlation id and an acquisition status in the form-encoded body of sign-in and sign-up requests.

Acquisition can never block or fail a sign-in: it is bounded by a deadline, and when no token can be obtained a status (`no_token`, `timeout`, `script_error`, `fetch_error`, `unsupported`, `http_<n>`) travels in its place and the request proceeds unchanged. Only the loader carrying the correlation id is governed by the shared token; any other configured loader is still applied on every page load. Nothing is stored in the browser unless a loader references `{cid}`, `{pid}` or `{rid}`.

`ProtectLoader` gains two optional fields: `tokenTimeoutMs`, and `tokenUrl` for instances that opt into fetching the token from a dedicated endpoint instead of taking the one served with the loader.

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 describes the technical implementation, but it doesn't really explain to an end user reading the release changelog what it does.

I'd favor a two sentence summary of the capabilities this gives instead, no need for this level of technical detail here.

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 — rewritten as what the release does rather than how. Two sentences: what it improves, and that it's inert unless the instance opts in and can never block or delay a sign-in. The placeholder names and the status list are gone; they're implementation detail and this repo is public.


/**
* Returns a session only when at least one loader references a placeholder — an instance not
* using any of them keeps today's behaviour exactly.

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.

NIT: "Today's behavior" wont age well. 😄

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.

Ha, fair. Reworded to "an instance using none of them is unaffected", and did the same at the one other site that had it.

clearTimeout(timer);
resolve(outcome);
};
element.addEventListener('load', settle('loaded'), { once: true });

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.

Not sure it's something that can happen in practice, but wanted to point out that since load only fires if src is set, this wont ever trigger if it's a pure inline script via textContent, which would put things in a bad state.

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, and it's real. A classic inline script executes during appendChild and fires neither load nor error, so this would have waited out the whole deadline, reported timeout, and discarded a token the script body had already assigned.

Worth flagging one wrinkle, because the obvious fix is wrong: it isn't "no src ⇒ no event". An inline module does fire load — it evaluates off a module graph the browser still has to fetch — so short-circuiting on src alone would settle before the module body ran and report no token instead. The guard now resolves early only for an element that fetches nothing and isn't an inline module. Both paths have a test, and the fixtures moved to type=module to match what's actually served.

Comment on lines +33 to +36
/** Acquisition deadline when the instance does not configure one. */
const DEFAULT_TOKEN_TIMEOUT_MS = 5 * 1_000;
/** Ceiling on the instance-configured deadline, so no server value can stall a sign-in. */
const MAX_TOKEN_TIMEOUT_MS = 10 * 1_000;

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.

These are per API request, not per sign-in/up flow right? So a combined sign in/up that transitions from sign-in to sign-up and fires multiple API requests might stall for twice the time in niche scenarios?

Feels like an edge case so not necessarily something we need to tackle, just wanted to highlight 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.

Correct, and worth having written down. Two things keep it off the common path: acquisition is prefetched at Clerk.load() rather than at sign-in, so a second request in the same flow reads the already-shared store rather than starting a run; and once an attempt settles without producing a token there's a cooldown before a fresh one is allowed, so back-to-back requests can't each pay a full deadline.

The worst case is still reachable — first load, acquisition failing, two requests far enough apart to clear the cooldown. Accepting it: the deadline is a ceiling on a request that would proceed anyway, and it never fails the sign-in.

Comment thread packages/clerk-js/src/core/protect.ts Outdated
// The config is server-controlled and cached, so nothing it can contain may take `Clerk.load()`
// down with it.
try {
this.#apply(config.loaders, config.id || undefined);

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.

id is typed as required, but treated as optional, all the way through to:

const suffix = instanceId ? `.${instanceId}` : '';
this.#tokenStorageKey = `${TOKEN_STORAGE_KEY}${suffix}`;

which made me start thinking about conflicting tokens stored under the same key etc before I realized it's probably expected to always be available?

If that's the case but we still want to be defensive at runtime (which I like), maybe we should validate its presence here and fail early instead (logging + early exit) to make the error predictable?

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.

You were right that the type and the code disagreed, and chasing it turned up more than expected: protect_config.id isn't sent at all. So instanceId was undefined on every load — the storage/lock key suffix never applied, and {instance_id} was never substituted, just left verbatim in the URL.

Resolved by removing it from the client rather than validating it: the instance id is the server's to place into the config it serves, not something the SDK interpolates. ProtectSession no longer takes it, {instance_id} is out of the placeholder set, and the store is explicitly one per origin. ProtectConfigResource also stops narrowing id to required — that narrowing was the mismatch you spotted. The JSON type keeps it, since ClerkResourceJSON requires an id of every resource (there's a TODO there asking the same question you did).

One consequence worth stating plainly: per-instance store namespacing is gone. It never actually applied, so nothing changes in practice, and the token names the instance that minted it and is verified server-side — two instances on one origin costs a rejected token, never one honoured for the wrong instance.

Comment on lines +250 to +258
/**
* The store is writable by anything running on the origin, so a value that could not have come
* from a mint of ours is discarded rather than trusted to suppress the loaders.
*
* The shape check is hygiene, not a security boundary: only the server can tell a real token from a
* well-formed forgery, and anything that can write the store can send the same values to the API
* directly. What it buys is that a corrupt or truncated entry starts a fresh run immediately
* instead of suppressing acquisition until it expires.
*/

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 is a great comment 👍

Review feedback from @Ephem on #9299.

A `<script>` with no `src` executes during `appendChild` and fires neither
`load` nor `error`, so `#runTokenLoader` waited on an event that could never
arrive: it spent the whole deadline, reported `timeout`, and discarded a token
the script body had already assigned. An inline module is the exception — it
evaluates off a module graph the browser still fetches, so its `load` does
arrive and settling early would read the global before it is written.

Also from that review:

- Drop `{instance_id}`. The instance id is the server's to place into the
  config it serves, not something the client interpolates, and the placeholder
  could never resolve anyway: `protect_config.id` is not sent, so it was left
  verbatim on every load. Removing it also removes the storage/lock key suffix
  it fed, which was likewise never applied. `ProtectConfigResource` stops
  narrowing `id` to required, which is what made the type disagree with the
  code. The wire type keeps it, since `ClerkResourceJSON` requires an id of
  every resource.

- The store comment claimed a per-instance namespace that never held. It now
  states what is true: one store per origin, with the token scoped to its
  minting instance and verified server-side.

- Rewrite the changeset as what the release does rather than how, and drop
  wording that dates itself.

Test fixtures move to `type=module`, matching the served loader, so they stay
on the event-driven path; the classic-inline and inline-module cases each get
their own test.
`clerk/javascript` is public, so a backend service name in a comment or a test
string is disclosure with no upside. The `__clerk_specter` global stays: it is
the wire contract with the loader script the server serves, not prose.

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

Actionable comments posted: 1

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

Inline comments:
In @.changeset/lucky-pandas-observe.md:
- Around line 6-8: Update the changeset description to clarify that the
session-token behavior requires both Protect and the relevant loader
configuration, not Protect alone. Replace the broad claim that an instance
stores nothing in the browser with the narrower statement that existing
instances store no additional browser data, while preserving the claims about no
blocking, delaying, or extra network data.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

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

Review profile: CHILL

Plan: Pro Plus

Run ID: b9ecb10e-18f3-4a0e-9c83-a8e71e81cd71

📥 Commits

Reviewing files that changed from the base of the PR and between 1151f46 and d490477.

📒 Files selected for processing (6)
  • .changeset/lucky-pandas-observe.md
  • packages/clerk-js/src/core/__tests__/protect.test.ts
  • packages/clerk-js/src/core/__tests__/protectSession.test.ts
  • packages/clerk-js/src/core/protect.ts
  • packages/clerk-js/src/core/protectSession.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/cli (auto-detected)
  • clerk/clerk-ios (auto-detected)
  • clerk/clerk-android (auto-detected)
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/clerk-js/src/core/tests/protect.test.ts
  • packages/clerk-js/src/core/protectSession.ts
  • packages/clerk-js/src/core/protect.ts

Comment thread .changeset/lucky-pandas-observe.md Outdated
Comment on lines +6 to +8
Improve Clerk Protect's ability to distinguish real users from automated sign-in and sign-up attempts, on instances that have Protect enabled.

This is inert unless your instance opts in, it can never block or delay a sign-in or sign-up, and an instance that does not use it stores nothing in the browser and sends nothing extra.

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Qualify the opt-in and storage claims.

The session-token behavior requires the loader configuration. Enabling Protect alone does not enable this feature. State that this feature stores no additional browser data instead of saying that the instance stores nothing in the browser.

The PR objective states that the feature is enabled through loader configuration and that existing instances store no additional browser data.

Proposed wording
-Improve Clerk Protect's ability to distinguish real users from automated sign-in and sign-up attempts, on instances that have Protect enabled.
+Add optional server-configured Protect session tokens to sign-in and sign-up requests for instances that opt in through loader configuration.

-This is inert unless your instance opts in, it can never block or delay a sign-in or sign-up, and an instance that does not use it stores nothing in the browser and sends nothing extra.
+This is inert unless your instance opts in through loader configuration. It does not block or delay authentication requests. Instances without this configuration retain current behavior, store no additional browser data, and send no additional parameters.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Improve Clerk Protect's ability to distinguish real users from automated sign-in and sign-up attempts, on instances that have Protect enabled.
This is inert unless your instance opts in, it can never block or delay a sign-in or sign-up, and an instance that does not use it stores nothing in the browser and sends nothing extra.
Add optional server-configured Protect session tokens to sign-in and sign-up requests for instances that opt in through loader configuration.
This is inert unless your instance opts in through loader configuration. It does not block or delay authentication requests. Instances without this configuration retain current behavior, store no additional browser data, and send no additional parameters.
🤖 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 @.changeset/lucky-pandas-observe.md around lines 6 - 8, Update the changeset
description to clarify that the session-token behavior requires both Protect and
the relevant loader configuration, not Protect alone. Replace the broad claim
that an instance stores nothing in the browser with the narrower statement that
existing instances store no additional browser data, while preserving the claims
about no blocking, delaying, or extra network data.

This repo is public, so the changelog cannot conceal the change — but it is
what people skim, and a note naming what the feature detects works as an index
entry pointing an adversary at the diff worth reading. The privacy-relevant
detail belongs in the customer docs, which are tracked separately, not here.
…ed clock

Both are outage behaviour rather than happy-path behaviour, so neither shows up
in normal testing.

The re-acquire cooldown was a flat 30s, so a Specter outage added the full
acquisition deadline to a sign-in every 30 seconds for as long as it lasted --
the auth-critical-path cost landing exactly when things are already worst. It
now doubles per consecutive tokenless attempt up to a 15m ceiling, low enough
that a long-lived tab still notices a recovery. Success is judged on whether a
token landed rather than on the status, since another tab may have won the lock
and written one while this attempt reported `timeout`.

A stored entry now carries `at`, the local time it was written. `exp` compares
server truth against the browser's clock, so a clock running slow keeps sending
lapsed tokens and one running fast discards good ones and re-runs the loader on
every page load; elapsed local time is measured on a single clock, so a constant
offset cancels. This bounds the damage rather than removing it -- a fully
skew-proof check needs the server to send a relative lifetime instead of an
absolute `exp`, which is a wire change.

Also stops sending a token inside a 5s margin of expiry: it could only fail
verification by the time it arrived, and reporting the acquisition status is the
honest answer. `StoredToken` and `MintedToken` are now separate types so `at` is
stamped by the write and a producer cannot forget it.

Test fixtures that hand-write the store go through one helper, so each case
overrides only the field under test -- several were about to start passing for
the wrong reason.
…very floor

`token_timeout_ms` has never been read. `ProtectConfig.fromJSON` assigns
`data.loaders` verbatim -- there is no case conversion anywhere in clerk-js --
so a loader keeps the shape the server sent, while the SDK looked for
`tokenTimeoutMs`. The wire name appears nowhere in clerk-js or shared, so
`clampTimeout` always saw `undefined` and every instance silently ran the 5s
default. The per-instance deadline that `JSLoaderConfig` documents did nothing.

Every test passed throughout, because the fixtures build `ProtectLoader` as a
TypeScript literal. A hand-built object typed by the same interface that
describes the wire agrees with itself by construction and can never catch a
name the server does not send. The new deadline test parses the wire shape
instead, which is the seam the defect lives on.

`ProtectLoader` is now typed in wire names throughout -- `text_content`,
`token_url`, `token_timeout_ms` -- because that is what the object is. Both
renamed fields are new in this PR and unreleased.

Adds `tokens_invalid_before` (unix seconds) to `protect_config`. A stored token
records the floor in force when it was acquired, and an entry below the
configured floor is discarded and re-acquired. This is recovery, not
revocation: an outstanding token stays cryptographically valid until it expires
or its signing key is dropped, and dropping a key from the verifier's list is
still what revokes. What this buys is that the fleet stops sending something the
server will not accept within a page load, rather than for the token's full 12h
lifetime. Absent on both sides means no floor, so introducing it does not make
every browser re-acquire at once.

The matching server field is a clerk_go change; until it lands this is inert,
which is the safe direction.
@zourzouvillys
zourzouvillys merged commit 44edcc9 into main Aug 12, 2026
84 of 85 checks passed
@zourzouvillys
zourzouvillys deleted the theo/js-session-token-clean branch August 12, 2026 00:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants