Skip to content

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

Draft
zourzouvillys wants to merge 1 commit 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 1 commit into
mainfrom
theo/js-session-token-clean

Conversation

@zourzouvillys

Copy link
Copy Markdown
Contributor

Description

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

What this adds:

  • An opaque correlation id. Two halves of 128 random bits from crypto.getRandomValues, lowercase unpadded RFC 4648 base32. One half persists at localStorage["__clerk_protect_pid"], the other is per-run. Nothing is derived from the device — no fingerprint input, no clock, no user data.
  • Placeholder interpolation in Protect.applyLoader. A closed set — {cid}, {pid}, {rid}, {instance_id} — substituted into loader attribute values before they are set. An unrecognised {…} is left verbatim, so an older SDK that does not interpolate stays compatible with the same config.
  • One acquisition per browser session, across all tabs. Runs inside the existing SafeLock (core/auth/safeLock.ts), the same primitive SessionCookiePoller uses, with the result in localStorage["__clerk_protect_st"]. Read the store → if valid and not near expiry, use it with no lock and no network → otherwise take the lock → re-read inside the lock → still absent, acquire → write → release. The re-read is what stops N tabs each acquiring in turn, and it is tested directly. Waiters abort on SafeLock's own signal so a wedged leader delays nobody; a leader tab closing mid-run releases its Web Lock automatically and a waiter picks up what it wrote.
  • Prefetch at Clerk.load(), not at sign-in, so the token is normally cached long before a user submits credentials. One attempt per page load, so a failure is not re-paid by every subsequent sign-in.
  • __clerk_protect_token / __clerk_protect_status / __clerk_protect_cid in the form-encoded body of sign-in and sign-up POSTs. Not the query string — a signed credential should not land in access logs along the path. Not a header — 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. This is an explicit merge step in fapiClient.request on a path allowlist, because the onBeforeRequest callbacks fire after the body is stringified.
  • Failure is always reported, never silent. ok | timeout | script_error | fetch_error | http_<n> | unsupported. The field is always populated; the request never blocks — on any failure the status travels in the token's place and the sign-in proceeds.

Judgement calls worth a reviewer's attention

  1. Nothing activates without server config. This only engages when a loader actually references one of the placeholders. An instance with today's plain src keeps exactly today's behaviour, stores nothing in the user's browser and sends no params.
  2. The endpoint is derived, or configured. ProtectLoader gains two optional fields, tokenUrl and tokenTimeoutMs. When tokenUrl is absent it is resolved against the loader URL carrying the {cid}. Configurations that cannot be derived must set tokenUrl explicitly. Keeping the URL shape in instance config is deliberate. Nothing server-side sets these fields yet; they are inert until it does.
  3. {instance_id} resolves from a field the server does not currently send, so today it is left verbatim. It starts working the moment that field is populated — no SDK change needed.
  4. A fresh cached token now suppresses loader injection. This is the source of the network-activity saving. Only applies to instances using the placeholders.
  5. unsupported is reserved for "cannot mint at all" (no crypto.getRandomValues). When localStorage is unavailable we fall back to an in-memory value for the page lifetime and keep going — losing cross-tab sharing but not the sign-in. Reporting unsupported for a run that actually succeeded would be a lie.
  6. Bundle size. +1.7 KB gzipped, which cleared the existing headroom on four bundles; thresholds bumped via pnpm bundlewatch:fix.
  7. rollout moved out of applyLoader into a module-level helper so load() can decide participation before it starts applying loaders. Same semantics, evaluated once.

Testing

packages/clerk-js: 66 files, 971 tests green. New coverage: base32 against the RFC 4648 vector, id shape and length, persistence across runs, the localStorage-throws fallback, the in-lock re-check proving one acquisition for two concurrent tabs (with a serialising Web Locks stand-in, since jsdom has none and the browser-tabs-lock mock always grants), every status path, placeholder interpolation including the unrecognised and no-value cases, and body-param attachment on the allowlisted paths only. tsc, eslint, prettier and bundlewatch clean.

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

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

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

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