Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

clerk-js 6 token cache ages session tokens by server `iat` against the browser clock — slow client clocks resend expired JWTs on every request

Open
#10,231 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
18/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active

Research direction

The cache logic lives in packages/clerk-js/src/core/tokenCache.ts: start with setInternal, where createdAt is set from the JWT iat, and get(), where eviction compares against Date.now() and POLLER_INTERVAL_IN_MS. Reproduce by shifting Date.now back 120 s in a Playwright page and checking whether a backend stub rejects tokens past their exp. Done means evicting on receipt-time or offset-corrected time so the stub sees no expired tokens, with the refresh timer also corrected.

Written by the indexing model from the issue text.

Description

Preliminary checks
  • I have searched existing issues: no report mentions the token cache's clock dependence ("clock skew", "iat", "token cache").
Package + version

@clerk/clerk-js 6.39.0 (loaded from the CDN by @clerk/nextjs 7.9.12 / Core 3). Verified against the 6.39.0 and main sources and compared with @clerk/clerk-js 5.128.0.

Description

packages/clerk-js/src/core/tokenCache.ts (6.x) ages a cached session token from the server-issued iat against the browser clock:

  • after a token resolves, setInternal sets live.createdAt = issuedAt (the JWT iat) and computes remainingTtl = exp − Math.floor(Date.now()/1000) for the eviction timer;
  • get() evicts only when (expiresIn − (now − createdAt)) <= POLLER_INTERVAL_IN_MS/1000 (5 s), where now is Date.now() and createdAt/expiresIn come from the token.

Every term except now is server time, so the eviction point moves with the browser's clock error. In 5.x the same cache stamped a locally fetched token with nowSeconds on arrival (createdAt = entry.createdAt ?? nowSeconds, expiresIn = exp − iat) and evicted 15 s early, so a wrong browser clock did not matter: the token was dropped ~45 s after the browser received it regardless of skew.

Consequence on 6.x. A browser whose clock is S seconds slow believes a 60 s token has 60 + S seconds of life. getToken() keeps returning it, with no error, until 55 + S seconds after issue. A backend that validates exp with the usual 30 s leeway rejects it from 90 s on, so for S > 35 s every API call from that browser fails for (S − 35) seconds out of every (55 + S) — on a clock 5 minutes slow, about three quarters of all requests. The proactive refresh timer (refreshFireTime = remainingTtl − leeway − 2) is scheduled from the same skewed remainingTtl, so it fires too late as well. Fast clocks only cause early refreshes.

What we observed (production, after upgrading @clerk/nextjs 6.39 → 7.9, i.e. CDN clerk-js 5 → 6)
  • a few long-lived sessions sending an expired token on every background poll for 30–60 min, then recovering when the cache finally evicted;
  • user-initiated PATCH/DELETE calls answered 401 for 3–13 minutes straight on those sessions;
  • backend log flooded with JWT expired: Signature has expired (up to ~2,500 rows / 4 h); getToken() never threw on the client, so no frontend error was logged;
  • no Clerk-side incident: fresh tokens validated fine fleet-wide the whole time.
Reproduction

Playwright, Clerk dev instance, a signed-in page whose Date.now is shifted 120 s into the past by an init script, polling an API stub that decodes the bearer exp and answers 401 when the real clock is more than 30 s past it:

== clerk-js 6.39.0, no app-side workaround: skew=120s, 22 backend calls over 290s ==
window   200   401  tokens
 30-60      3     0  …kt0zl9VA
 60-90      1     0  …kt0zl9VA
 90-120     2     0  …kt0zl9VA
120-150     0     2  …kt0zl9VA
150-180     0     4  …kt0zl9VA
180-210     1     0  …Z7olUPNw
210-240     2     0  …Z7olUPNw
240-270     1     0  …Z7olUPNw
270-300     0     4  …Z7olUPNw
300-330     0     2  …Z7olUPNw
total: 22 calls, 12 rejected (55%), 2 distinct tokens

== same, with our skipCache-on-rejected workaround: skew=120s, 19 backend calls over 290s ==
window   200   401  tokens
 30-60      3     0  …MbqVOcNw
 60-90      1     0  …MbqVOcNw
 90-120     2     0  …MbqVOcNw
120-150     1     1  …MbqVOcNw …AFLN8Tyw
150-180     2     0  …AFLN8Tyw
180-210     1     0  …AFLN8Tyw
210-240     2     1  …AFLN8Tyw …jgxTSUHQ
240-270     1     0  …jgxTSUHQ
270-300     2     0  …jgxTSUHQ
300-330     1     1  …jgxTSUHQ …__qXlmuQ
total: 19 calls, 3 rejected (16%), 4 distinct tokens

(the API stub answers 401 once a token is >90 s old by the real clock; without the workaround every poll AND its retry carry the same stale token until clerk-js evicts it ~175 s after issue)
Expected behavior

Freshness measured on the browser clock from receipt (as 5.x did), or createdAt corrected by the observed server/client offset (iat vs Date.now() at receipt, or the Date header of the /tokens response).

Workaround we shipped

Record the bearer a backend 401 refused and have our getToken wrapper call getToken({ skipCache: true }) when the cache hands that same string back (one shared in-flight mint). Reactive, not a fix.

Dominant language
TypeScript
Stars
1.8k
Forks
480
Avg merge
1d 19h
Merged PRs (30d)
285

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from clerk/javascript

All issues in clerk/javascript

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.