Skip to content

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

Draft
zourzouvillys wants to merge 3 commits into
mainfrom
theo/js-session-token-clean
Draft

feat(js): attach an optional server-configured session token to sign-in#9299
zourzouvillys wants to merge 3 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: 7a76ac2

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 Jul 31, 2026 11:10pm
swingset Ready Ready Preview Jul 31, 2026 11:10pm

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: 7a76ac2

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

1 participant