') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(clerk-js): Monotonic token replacement based on oiat by nikosdouvlis · Pull Request #8097 · clerk/javascript · GitHub
Skip to content

feat(clerk-js): Monotonic token replacement based on oiat - #8097

Merged
nikosdouvlis merged 23 commits into
mainfrom
nikos/session-minter-sdk-changes
May 14, 2026
Merged

feat(clerk-js): Monotonic token replacement based on oiat#8097
nikosdouvlis merged 23 commits into
mainfrom
nikos/session-minter-sdk-changes

Conversation

@nikosdouvlis

@nikosdouvlisnikosdouvlis commented Mar 17, 2026

Copy link
Copy Markdown
Member

Why

With Session Minter, edge-minted tokens can have fresh iat (just minted) but stale claims (copied from an old parent). In multi-tab BroadcastChannel scenarios, a background tab's stale edge token can clobber a fresher DB-minted token in another tab's cache because the old broadcast guard compared iat only, which doesn't reflect claim freshness for edge-minted tokens.

What

Introduces oiat (origin-issued-at, JWT header) as the claim freshness metric for the cross-tab broadcast comparison:

  • Token with oiat: oiat = when claims were last read from DB
  • Token without oiat: pre-feature legacy token, by definition staler than any oiat-bearing token

Decision table

pickFreshestJwt(existing, incoming) returns whichever side wins. The broadcast handler uses === existing to detect "skip this broadcast".

#ex.oiatin.oiatcomparisonwinnerwhy
A110090ex.oiat > in.oiatexistingincoming claims older
A290100ex.oiat < in.oiatincomingincoming claims newer
A3100100equal, ex.iat > in.iatexistingsame claims, existing minted later
A4100100equal, ex.iat < in.iatincomingsame claims, incoming minted later
A5100100equal, ex.iat == in.iatincomingfull tie, let incoming through (other claims may differ)
B1100nullincoming has no oiatexistinglegacy = pre-feature stale
B2null100existing has no oiatincominglegacy = pre-feature stale
B3nullnullboth have no oiatincomingcannot rank, safe default

A1-A5 is the main path under universal oiat. B1-B3 is the legacy safety net for pre-rollout tokens. On a full tie (A5), incoming wins rather than existing: two tokens with identical oiat+iat may still differ in other claims (azp, org_id, etc.) added in a token-format rollout, so we only suppress when existing is strictly fresher.

Guard site

The shared comparator (tokenFreshness.ts) is invoked at one site:

  • tokenCache.ts handleBroadcastMessage - replaces the old iat comparison on cross-tab broadcasts

The Session resource and AuthCookieService originally also added guards but those caused regressions on the auth flow (suppressing legitimate token-update events that AuthCookieService needs to write the cookie) and have been removed. The broadcast handler is the only site where a stale edge-minted token could realistically race with a fresher one without other in-flight protections.

Test plan

  • Broadcast: edge token with fresh iat but stale oiat is rejected
  • Broadcast: oiat-newer token replaces oiat-older token in the same tab
  • First mint with no existing token passes unconditionally
  • Comparator: all 8 decision table rows

@changeset-bot

changeset-botBot commented Mar 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e157d4d

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

This PR includes changesets to release 3 packages
NameType
@clerk/clerk-jsPatch
@clerk/chrome-extensionPatch
@clerk/expoPatch

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

vercelBot commented Mar 17, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentMay 14, 2026 1:32pm

Request Review

@nikosdouvlis
nikosdouvlisforce-pushed the nikos/session-minter-sdk-changes branch from dc3c6e9 to cf6803dCompareMarch 18, 2026 09:59
@nikosdouvlis
nikosdouvlis changed the base branch from nikos/plat-2566-session-minter-sdk-params to nikos/plat-2566-send-token-on-refreshMarch 18, 2026 13:17
@nikosdouvlis
nikosdouvlisforce-pushed the nikos/plat-2566-send-token-on-refresh branch from f0b2a14 to cbc83a0CompareMarch 18, 2026 13:23
@nikosdouvlis
nikosdouvlis changed the base branch from nikos/plat-2566-send-token-on-refresh to mainMarch 18, 2026 13:23
Session Minter uses oiat (original_issued_at) in the JWT header to
track when token claims were last assembled from the DB. Edge
re-mints copy this value forward, so consumers can determine claim
freshness regardless of how many times the token was re-signed.
Marked @internal so developers don't depend on this field.
@nikosdouvlis
nikosdouvlis changed the base branch from main to nikos/plat-2469-oiat-jwt-header-typeMarch 18, 2026 13:24
Prevent multi-tab race conditions where an edge-minted token with
stale claims overwrites a fresher DB-minted token.
Uses `oiat ?? iat` as the claim freshness metric. A token with oiat
(JWT header) uses oiat as its claim freshness. A token without oiat
is origin-minted (coupled FF), so iat represents claim freshness.
Four guard points:
1. tokenCache handleBroadcastMessage - replaces old iat comparison
2. tokenCache setInternal - async compare-and-swap at resolve time
3. Session #dispatchTokenEvents - before token:update emit
4. AuthCookieService updateSessionCookie - cookie chokepoint with
session scoping (different sessions always allowed through)
Guard 4 catches the sleeping tab edge case where in-memory guards
pass (stale baseline) but the cookie has a fresher value from
another tab.
ESLint simple-import-sort/imports flagged pickFreshestJwt as misplaced.
Move it to the correct alphabetical slot (after resources/Environment,
before ./cookies/...).
The cookie write guard at AuthCookieService.updateSessionCookie was
causing integration test failures across handshake, sessions, and
multiple framework matrices. The guard would reject token writes when
oiat+iat matched, but two tokens with identical timestamps can still
differ in OTHER claims (azp added in a recent token-format rollout,
org_id, etc.). Backend then logged 'Session token from cookie is
missing the azp claim' and treated the session as invalid, redirecting
to /sign-in.
The broadcast handler (tokenCache.ts:292) and Session resource
(Session.ts:463, :526) keep the monotonic enforcement at the layers
where it works correctly. The cookie chokepoint was too aggressive.
The cookie path
deserves a guard but with a different shape (e.g., raw-string equality
or signature compare), not the claim-timestamp shape.
return;
}

if (this.lastActiveToken && pickFreshestJwt(this.lastActiveToken, token) === this.lastActiveToken) {

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.

The suppression test at Session.test.ts:103 only proves the guard can suppress, not that it does so for the right reason.

And every test that asserts dispatch=2 (lines 186, 242, 60) constructs the session without last_active_token, so the this.lastActiveToken && … short-circuit means the comparator is never actually called.

We should probably add a test where lastActiveToken is set with stale oiat, a fresher-oiat token arrives via cache (broadcast from another tab) or fetch, and assert token:update fires with the fresher token

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

Minor comment re:tests, LGTM!

Two tokens with identical oiat+iat may still differ in other claims
(azp, org_id, etc.) added in a token-format rollout. The previous
'no churn on tie' rule suppressed legitimate updates and caused the
backend to read stale claim sets, redirecting to /sign-in. Only
suppress when existing is strictly fresher.
The Session.ts guards at #_getToken cache-hit emit and
#dispatchTokenEvents were suppressing token:update events that
AuthCookieService needs to write the session cookie. Backend then
saw an empty/stale cookie and treated the session as unauthenticated.
Keep only the broadcast handler guard in tokenCache.ts, which covers
the original motivation: cross-tab races where a background tab's
stale edge-minted token can clobber a fresher DB-minted token via
the BroadcastChannel.
…er cached one
Inverse of the existing 'older broadcast does not overwrite newer'
test. Confirms the monotonic guard is direction-correct: a fresher
oiat replaces an older cached entry rather than being suppressed.
The handler is async (awaits the existing tokenResolver), so reading
the cache synchronously after broadcastListener() captures the
pre-await state. Type the listener as returning void | Promise<void>
and await it so the second broadcast finishes processing before we
assert the new createdAt.
…ehavior
Doc said 'on a tie, returns existing (no churn)' but the implementation
returns incoming on full ties. Rewrite the doc to match: only suppress
when existing is strictly fresher; on a tie, hand through to incoming
since the two tokens may differ in claims that don't affect freshness.
Also await the broadcast handler in the older-rejected monotonicity
test so it doesn't pass vacuously when the async handler runs after
the assertion. Same shape as the newer-replaces-older test.
The cache.get path drops entries whose iat+ttl is past the current
test clock (nowSec=1666648260, POLLER_INTERVAL=5s). With the default
ttl=60, the older token (iat=1666648190, exp=1666648250) was 10s
expired and got purged before the assertion could read it.
Use ttl=120 so both older (exp=1666648310) and newer (exp=1666648370)
stay valid against the fixed test clock.
@nikosdouvlis
nikosdouvlis merged commit 1e2e237 into mainMay 14, 2026
43 checks passed
@nikosdouvlis
nikosdouvlis deleted the nikos/session-minter-sdk-changes branch May 14, 2026 13:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@nikosdouvlis@bratsos@github-advanced-security