plugins/clerk-auth-master/skills/clerk-clock-skew-jwt/SKILL.md
Use for Clerk JWT and session token clock-skew issues. PROACTIVELY activate for "token-not-active-yet", "JWT cannot be used prior to not before", token-expired, nbf/iat/exp claim failures, clockSkewInMs in verifyToken/authenticateRequest, local dev container/WSL2/Docker Desktop time drift, NTP misconfiguration, paused VM clock, serverless cold-start clock issues, custom JWT template verification leeway across Node/Python/Go/Ruby/Java/.NET/PHP, debugging iat vs server-time delta, and separating real expiry from clock skew. Provides nbf/iat/exp semantics, Clerk and library clock-tolerance defaults, container time fix recipes, multi-language leeway patterns, diagnostics, and gotchas.
npx skillsauth add JosiahSiegel/claude-plugin-marketplace clerk-clock-skew-jwtInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Clerk session tokens and custom JWT templates are signed JSON Web Tokens. The verifier always checks three time-based claims:
| Claim | Meaning | Failure error |
|---|---|---|
| iat (issued at) | When the token was minted | (rare) invalid-iat or iat must be in the past |
| nbf (not before) | Earliest moment the token is valid | token-not-active-yet (Clerk error reason) |
| exp (expiration) | Latest moment the token is valid | token-expired (Clerk error reason) |
Clock skew occurs when the verifier's wall clock is offset from the issuer's wall clock. If the verifier is behind, even freshly minted tokens fail nbf. If the verifier is ahead, long-lived tokens can fail exp early. Both directions break auth.
Clerk's own backend SDK applies a small tolerance (clockSkewInMs default 5000 ms) and the Dashboard exposes a session-level "Allowed Clock Skew" / nbf offset setting for JWT templates. Third-party libraries used to verify Clerk-issued JWTs have their own leeway settings; the safe defaults differ by library.
A decoded Clerk session token typically contains numeric epoch-second values for iat, nbf, and exp. Decode the JWT (without verification) at a tool like jwt.io to inspect them when diagnosing failures.
The verifier must check nbf <= now + leeway and exp >= now - leeway. A token that is "active" must satisfy both bounds. If nbf > now even by one second, the token is rejected with token-not-active-yet. If exp < now, the token is rejected with token-expired.
iat is informational; most libraries do not require it. Clerk's tokens do include iat, and some libraries (for example golang-jwt/jwt v5) require WithIssuedAt() to enable iat validation.
Clerk's verifyToken() (in @clerk/backend, @clerk/nextjs/server, and equivalent) accepts a clockSkewInMs option. Documented default: 5000 (5 seconds). The same option is exposed to authenticateRequest for HTTP requests.
Conceptual shape (Node, TypeScript):
import { verifyToken } from '@clerk/backend'
const payload = await verifyToken(token, {
secretKey: process.env.CLERK_SECRET_KEY!,
clockSkewInMs: 5000, // 5s tolerance for nbf/exp
audience: 'my-api',
authorizedParties: ['https://app.example.com'],
})
The Clerk Dashboard also exposes a session-token "Allowed Clock Skew" / nbf offset. Treat the Dashboard setting as the user-facing knob; treat clockSkewInMs in code as the same concept. Do not stack them to a large effective tolerance.
Local development environments are the most common source of clock skew failures because clocks are sometimes virtualized, suspended, or never NTP-synced.
| Environment | Typical cause | Symptom |
|---|---|---|
| Bare-metal host | NTP disabled, firewall blocks UDP 123 | Host clock drifts seconds per day |
| WSL2 (Windows) | Hyper-V time sync not exposed; hwclock diverges from Windows host | Container time lags Windows time after sleep/resume |
| Docker Desktop (macOS/Windows) | VM clock paused while laptop sleeps | Containers wake with a stale clock |
| Linux container (Docker Engine) | No NTP inside the container; bind-mounts host time as a path | If host is wrong, container is wrong |
| Kubernetes pod | Some runtime images lack chrony/systemd-timesyncd | Drift after long-lived pods |
| CI runners (GitHub Actions, etc.) | Ephemeral VMs with no synced clock between jobs | iat may be milliseconds in the future |
| Serverless cold start (Lambda, Vercel, Cloudflare Workers) | Provider clock can differ slightly from yours; GC pauses cause skew | First request after cold start occasionally fails |
| Suspended/paused VM (VMware, VirtualBox, Hyper-V) | Wall clock stops on suspend, may jump on resume | Large skew after wake |
| Manual clock changes | Admin sets time forward or backward | Token issued at the new time is then re-verified against the old time |
Clerk mints tokens on its own servers (so its clock is NTP-disciplined). The local server verifying them is the variable side. Fix the verifying side's clock first; only adjust leeway as a secondary control.
The remediation order matters. Always start with the clock; only add leeway after the clock is known-good.
Capture the host time and the in-container time:
# Host
date -u +%s
date -u --iso-8601=seconds
# Inside a container
docker exec <container> date -u +%s
docker exec <container> date -u --iso-8601=seconds
If they differ by more than a few seconds, the container is the source of the problem. The same script works for kubectl exec against a pod.
WSL2 runs in a lightweight VM; its clock is independent of Windows unless explicitly synchronized. After the Windows host sleeps, WSL2 may resume with a stale clock.
# Inside WSL2
sudo hwclock -s # sync to hardware clock (may also be stale)
sudo chronyc tracking # check NTP sync state if chrony is installed
sudo ntpdate pool.ntp.org # one-shot sync
sudo timedatectl status # confirm NTP enabled
If timedatectl reports NTP inactive, enable it:
sudo timedatectl set-ntp true
If the underlying Windows clock is correct but WSL2 still drifts, restart the WSL2 VM from PowerShell:
wsl --shutdown
wsl
Docker Desktop on macOS and Windows runs an embedded Linux VM. Its clock is normally synced to the host, but pauses of the host (laptop sleep, suspended VM) leave the embedded VM with a stale time.
# Quick resync from inside the container
docker exec <container> date -s "$(date -u +%Y-%m-%dT%H:%M:%S)Z"
# Or restart Docker Desktop to fully reset the VM clock
# macOS: open the Docker Desktop UI and choose Troubleshoot -> Restart
# Windows: right-click tray icon -> Restart
The reliable fix is to keep the host awake (disable sleep) or restart Docker Desktop after long pauses.
Many minimal images (alpine, distroless, node:alpine) lack a time-sync daemon. Bind-mount the host's /etc/localtime and ensure the host itself is NTP-synced:
# In the Dockerfile or docker run
-v /etc/localtime:/etc/localtime:ro
For deeper control, install a time-sync client inside the image:
RUN apk add --no-cache tzdata chrony || apt-get update && apt-get install -y --no-install-recommends chrony
For Kubernetes, prefer a node-level chrony or systemd-timesyncd; do not try to NTP-sync inside every pod.
Ephemeral CI runners rarely have time-sync issues for short jobs, but multi-minute pauses (queued jobs, large checkout) can drift. Force a sync at job start:
# GitHub Actions
- name: Sync clock
run: sudo sntp -S time.cloudflare.com
If even after sync the test intermittently fails, capture date -u +%s at token-issue and token-verify time during the failing test; compare to the token's nbf/iat/exp. If the test environment cannot be fixed, gate test-only flows with a documented clockSkewInMs override.
Lambda, Vercel, and Cloudflare Workers generally have well-disciplined clocks. Skew is rare. If it occurs, capture the cold-start time and token nbf from a log line; if the difference is reproducible, raise an issue with the provider and use clockSkewInMs as a temporary buffer.
When verifying Clerk-issued tokens outside Clerk's own helpers, configure the library's leeway explicitly. Defaults are not consistent and not always large enough.
jsonwebtokenclockTolerance is in seconds and applies to both nbf and exp. Default is 0.
import jwt from 'jsonwebtoken'
const claims = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
clockTolerance: 30, // 30 seconds
issuer: 'https://clerk.<instance>.com',
audience: 'my-api',
})
Pass a number, never a string. A string value causes silent concatenation bugs. Do not combine with ignoreNotBefore: true or ignoreExpiration: true in production.
jose (panva)clockTolerance accepts a number of seconds or a string like "30s", "2m", "1h". Use the string form for readability.
import { jwtVerify } from 'jose'
await jwtVerify(token, jwks, { clockTolerance: '30s' })
PyJWTleeway is in seconds and applies to both nbf and exp. Default is 0. Can also be a timedelta.
import jwt
decoded = jwt.decode(
token,
key=public_key,
algorithms=["RS256"],
leeway=30,
options={"require": ["exp", "iat"]},
audience="my-api",
issuer="https://clerk.<instance>.com",
)
Exceptions: ExpiredSignatureError (exp), ImmatureSignatureError (nbf), InvalidIssuedAtError (iat).
python-josepython-jose accepts leeway (seconds) in its options. PyJWT is generally recommended for new code because python-jose is less actively maintained.
from jose import jwt
decoded = jwt.decode(
token,
key=public_key,
algorithms=["RS256"],
options={"leeway": 30, "verify_exp": True, "verify_nbf": True},
audience="my-api",
)
golang-jwt/jwt/v5Use jwt.WithLeeway(duration) to add clock skew tolerance and jwt.WithIssuedAt() to enable iat validation. WithExpirationRequired() and WithNotBeforeRequired() enforce presence of the claims.
token, err := jwt.ParseWithClaims(
raw, &MyClaims{}, keyFunc,
jwt.WithLeeway(30*time.Second),
jwt.WithIssuedAt(),
jwt.WithExpirationRequired(),
jwt.WithIssuer("https://clerk.<instance>.com"),
jwt.WithAudience("my-api"),
jwt.WithValidMethods([]string{"RS256"}),
)
jwt gemUse the leeway key (in seconds) in the options hash. The gem applies the same value to both exp and nbf. Despite older documentation references, prefer the single :leeway key.
payload, _header = JWT.decode(
token, public_key, true,
algorithm: "RS256",
leeway: 30,
verify_iat: true,
iss: "https://clerk.<instance>.com",
aud: "my-api",
)
Nimbus JOSE + JWTDefaultJWTClaimsVerifier exposes setMaxClockSkew(int seconds). Default skew in Nimbus is 60 seconds. If you need to disable iat checks, set the verifier's required-claim set accordingly.
JWTClaimsSet expected = new JWTClaimsSet.Builder()
.issuer("https://clerk.<instance>.com")
.audience("my-api")
.build();
DefaultJWTClaimsVerifier<?> verifier =
new DefaultJWTClaimsVerifier<>(expected, Set.of("sub", "exp", "iat"));
verifier.setMaxClockSkew(60); // 60 seconds
DefaultJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
processor.setJWTClaimsSetVerifier(verifier);
Auth0 java-jwtJWTVerifier exposes acceptLeeway(long seconds).
JWTVerifier verifier = JWT.require(Algorithm.RSA256(publicKey, null))
.withIssuer("https://clerk.<instance>.com")
.withAudience("my-api")
.acceptLeeway(30)
.build();
DecodedJWT decoded = verifier.verify(token);
Microsoft.IdentityModel.TokensTokenValidationParameters.ClockSkew is a TimeSpan. The Microsoft default is 5 minutes (TimeSpan.FromMinutes(5)). For Clerk integrations this default is generous; reduce it to 30-60 seconds for tighter validation.
var validationParams = new TokenValidationParameters
{
ValidIssuer = "https://clerk.<instance>.com",
ValidAudience = "my-api",
IssuerSigningKeys = jwks,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
RequireExpirationTime = true,
};
firebase/php-jwtSet the static JWT::$leeway property in seconds. Applies to exp and nbf during JWT::decode.
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
JWT::$leeway = 30; // 30 seconds
$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256'));
Avoid the deprecated exp_leeway / nbf_leeway keys.
Clock skew and a legitimately expired token can both look like a 401 with a token error. Use this decision tree to separate them.
iat, nbf, exp.date +%s or Date.now()/1000).nbf - now: if greater than 0, the token is "not yet valid". A few seconds is clock skew; many minutes is wrong environment clock.now - exp: if greater than 0, the token is expired. A few seconds is clock skew; a long positive value is a real expiry.now - iat: if negative (token from the "future"), the issuer is ahead of the verifier.nbf to iat:
nbf == iat, Clerk issued the token and made it immediately valid. A small nbf > now is purely verifier clock lag.nbf > iat by a configurable offset, the Dashboard "Allowed Clock Skew" / nbf offset is at play. Check the Dashboard.date -u and docker exec <container> date -u).getToken() call works, the old token's exp was crossed. If even a fresh token fails, the verifier clock is wrong.The error reason is the single most useful piece of information. Look for:
token-not-active-yet - nbf is in the future relative to verifier time.token-expired - exp is in the past relative to verifier time.invalid-iat - iat is in the future (rare; usually clock skew the other direction).jwt-verification-failed - signature, issuer, or audience mismatch; usually not clock skew.A larger leeway window accepts tokens for a longer time after they should have expired, and accepts them earlier than the issuer intended. Pick the smallest leeway that absorbs real clock drift.
| Leeway | Acceptable for | |---|---| | 0-5 s | Tight production where clocks are NTP-disciplined (most cloud) | | 30-60 s | Default for production with NTP and a small safety margin | | 1-5 min | Local development, CI, or environments with known drift | | > 5 min | Almost never acceptable; weakens security and complicates revocation |
The .NET TokenValidationParameters.ClockSkew default of 5 minutes is widely cited as too generous for production APIs. Set it to 30-60 seconds unless there is a documented reason for more.
A common anti-pattern is fixing only the leeway without fixing the underlying clock. The verifier remains incorrect for other consumers, future operators, and audit logs. Treat leeway as a safety net, not the fix.
clerkMiddleware() and auth.protect() do not allow callers to pass clockSkewInMs directly. If you need a custom value, use the underlying verifyToken helper or your own session-token verification.timedatectl set-ntp true plus restarting the WSL VM is the reliable fix.iat milliseconds in the future; this is normal and Clerk's 5-second default absorbs it.nbf/iat/exp claims as session tokens. The same leeway considerations apply to any service that verifies them.timestamp header with a freshness window, typically 5 minutes) that is separate from JWT clock skew. Do not conflate the two. A webhook failing the Svix freshness check is a different failure mode than a JWT failing nbf.nbf/exp integers. Do not guess from error text alone.timedatectl; Windows: W32Time service; macOS: sntp).chrony or an init system that syncs time.clockSkewInMs set explicitly to a small value (5-30 seconds) where Clerk's own helpers are used..NET ClockSkew reduced from 5 minutes to 30-60 seconds in production TokenValidationParameters.token-not-active-yet / token-expired errors; sudden change usually indicates a clock problem.nbf/iat/exp.development
Use for Clerk sessions, tokens, webhooks, orgs, and security. PROACTIVELY activate for session tokens, JWT templates, getToken(), custom claims, pending sessions, multi-session UX, organizations, roles, permissions, system vs custom permissions, features/plans, MFA/passkeys/password policy/bot protection, Clerk webhooks, Svix signatures, verifyWebhook(), user/org sync, retries/replays, environment variables, custom domains, secret rotation, logs, and auth security reviews. Provides token semantics, webhook idempotency, authorization defaults, and hardening checklist.
tools
Use for Clerk in Next.js. PROACTIVELY activate for @clerk/nextjs setup, App Router auth()/currentUser(), clerkMiddleware(), proxy.ts/middleware.ts, createRouteMatcher(), protected pages/layouts/Route Handlers/Server Actions/API routes/tRPC, auth.protect() role/permission/token checks, ClerkProvider placement, server-only clerkClient, Link prefetch, redirects, 401/404 auth failures, custom domains, __clerk proxy paths, and deployment gotchas. Provides file patterns, server/client boundary rules, matcher templates, and production checks.
development
Use for Clerk frontend auth flows. PROACTIVELY activate for React, JavaScript, Vue, Nuxt, Astro, Expo, React Router, TanStack React Start, or SPA setup; ClerkProvider and publishable-key wiring; SignIn/SignUp/UserButton/UserProfile/OrganizationSwitcher; custom useUser/useAuth/useClerk/useSignIn/useSignUp/useSession/useOrganization flows; multi-session UX; cross-origin getToken() fetches; loading states, redirects, routing, CORS/cookies, or hydration bugs. Provides SDK selection, UI patterns, token-fetch templates, and frontend gotchas.
development
Use for Clerk dev/prod readiness, deployment, and multi-language implementation planning. PROACTIVELY activate for environment variables, pk_test/sk_test vs pk_live/sk_live, local dev, preview/staging/prod instances, domains/DNS, redirects, OAuth credentials, custom domains/proxy, authorizedParties, CSP, CORS/cookies, webhooks/tunnels, Vercel/Netlify/Cloudflare/API gateways, monitoring/troubleshooting, and backends in Node/Express/Fastify, Python/FastAPI/Django/Flask, Go, Ruby/Rails, Java/Spring, .NET, PHP/Laravel. Provides checklists, rollout plans, and language-portable patterns.