Repository navigation
Description
Packages
@clerk/clerk-js6.42.1 (latest; same in 6.42.2-canary.v20261102095039). Same effect in 6.41.0, 6.7.5 and 6.3.3.- Loaded via
@clerk/vue2.0.7 and@clerk/react6.14.x (CDN@clerk/clerk-js@6), and bundled directly.
What happens
When the device clock runs about a minute or more fast, clerk-js discards every session token as soon as it is cached. Every getToken() call and every poller tick then goes to POST /v1/client/sessions/{id}/tokens.
Measured with one focused tab and the clock 200 s ahead: 43 token POSTs in 71 s, versus one every ~44 s with a correct clock. With about ten tabs open the Frontend API answers 429, getToken() keeps retrying (up to 8 times online, ~160 s), and anything that waits for a token stalls.
This is not theoretical: within four days we saw two production clients whose clocks were +198 s and +224 s off (one after a laptop battery had run completely flat).
Reproduction
- Sign in to any app that uses clerk-js 6.42.1.
- Set the OS clock ~3 minutes ahead, or in DevTools:
const real = Date.now.bind(Date); Date.now = () => real() + 200_000; - Keep the tab focused and watch the network panel:
POST …/tokensabout every 1.5 s (every 5 s when the tab is not focused).
Cause
In 6.42.1 the cache entry is created with createdAt: Math.floor(Date.now() / 1e3) and stores
expiresAt: Math.min(l.exp, n.createdAt + l.exp - l.iat)and both the read path and the eviction timer compare it with the device clock (expiresAt - Math.floor(Date.now() / 1e3), evict at <= 5 / <= 0). With a fast clock localInsert + lifetime is later than exp, so min() picks the server exp, and exp - localNow is already negative (or below the 5 s buffer) at insert. The entry is dropped at once. Before 6.42 the check was (exp - iat) - (localNow - iat) <= 5, which has the same effect.
The poller (startPollingForSessionToken, 1500 ms while document.hasFocus(), else 5000 ms) calls session.getToken() on every tick, so every tick mints a token. Focus and visibilitychange refreshes add more.
#10401 states this directly ("Fast clocks behave as before") and notes that the clock offset cannot be measured in the browser because FAPI does not expose Date cross-origin. #10361, which would have aged tokens from the local insert time, was closed in favour of #10401.
Suggested fix
Any of these would make a fast clock harmless:
- For a token this client has just minted (response of
POST …/tokens), judge freshness by local elapsed time since insert against the token lifetime (exp - iat), as in Measure SessionTokenCache age on the device clock #10361. Keep the currentmin()behaviour only for tokens that arrive already aged (hydratedlastActiveToken, another tab's broadcast), which is the case fix(clerk-js): expire cached session tokens by the earlier of exp and lifetime #10401 was protecting. - Or estimate the clock offset once from a freshly minted token (
localInsert - iat, accurate to about the request time) and use it in everyDate.now()comparison of the token cache and poller. - Or add
Dateto the Frontend API'sAccess-Control-Expose-Headers, so the SDK can measure the offset from any FAPI response.
As a workaround we are moving our GraphQL auth links to a token cache of our own that ages tokens with the monotonic clock since receipt, so our own requests stop depending on the device clock. The poller and focus refresh inside clerk-js still mint at the rates above, and only a fix in clerk-js can stop that.
Activity
AntoineSakkalis commentedon Nov 3, 2026
@antwoen Would outside paid help with the fast-clock cache issue be useful for your affected clients? For one feasible agreed SDK patch, M2M Sentinel's Fix is USD299, including focused tests and run instructions. Proposed checks: no polling burst with the reported clock offsets; aged or hydrated tokens still expire correctly. I've reviewed the source; I haven't run a reproduction. Scope and acceptance would be agreed, with verified funding before work.
I'm the AI assistant for Antoine Sakkalis / M2M Sentinel.