Introduction — The Most Common JWT Failure
Of every error a JSON Web Token can throw, "token expired" is the one developers meet most often, and the one most likely to send them down the wrong rabbit hole. The symptom is maddening: a login that worked perfectly five minutes ago now returns 401 Unauthorized, the payload looks completely valid, and nothing in your application code changed. Almost always, the culprit is a single registered claim — exp — and a misunderstanding of how verifiers evaluate it. This guide explains exactly what the exp claim is, why it rejects tokens that look fine, and how to fix every common variant of the "token expired" error. Throughout, you can follow along by pasting your failing token into our JWT Decoder & Inspector, which decodes claims and converts timestamps to readable dates entirely in your browser.
What the 'exp' Claim Actually Is
The exp (expiration time) claim is defined in RFC 7519, Section 4.1.4, the IETF specification for JSON Web Tokens. The spec is precise about two things people routinely get wrong. First, exp is a NumericDate: the number of seconds — not milliseconds — since the Unix epoch (1970-01-01T00:00:00Z), as defined in RFC 7519 Section 2. Second, the rule is simple and absolute: "the current date/time MUST be before the expiration time listed in the exp claim." If now >= exp, the token is expired and MUST be rejected.
That word "MUST" matters. A conforming verifier is not being lenient or strict by choice; rejecting an expired token is mandated behavior. This is why you cannot "fix" an expired token by re-sending it — the only correct response is to obtain a fresh one. The decoder makes the situation obvious by showing the exp value as a human-readable date and flagging whether it is in the past.
The Number One Cause — Milliseconds vs Seconds
The single most frequent exp bug in the wild comes from JavaScript. Date.now() returns milliseconds, but RFC 7519 demands seconds. A developer who writes exp: Date.now() + 3600 creates a token that appears to expire roughly 50,000 years in the future, because the millisecond value is interpreted as seconds. Conversely, signing libraries that correctly emit seconds will be misread by hand-rolled verification code that compares against Date.now() directly, making every token look expired.
The fix is to standardize on seconds everywhere: Math.floor(Date.now() / 1000) on the client, and a comparison of exp against the same unit on the server. When you paste a suspicious token into the JWT Decoder and the expiry date reads as a year deep in the future or in 1970, you have found a units bug immediately.
Clock Skew — When Both Sides Disagree on 'Now'
The most insidious "token expired" errors happen when the token is genuinely fresh but the verifying server's clock is ahead of the issuer's. exp validation compares a timestamp baked into the token against the verifier's local wall clock. If the verifier's clock runs two minutes fast, it will reject tokens that have two minutes of life left from the issuer's perspective.
RFC 7519 anticipated this. Section 4.1.4 explicitly permits implementers "to provide for some small leeway, usually no more than a few minutes, to account for clock skew." This leeway is why most mature libraries accept a configurable tolerance — often defaulting to 30 or 60 seconds. If you see intermittent expiry rejections on a single node in a cluster, suspect that node's clock. The durable fix is not larger leeway but disciplined time synchronization: run NTP (or chrony) on every host, and in containerized environments ensure the host clock is correct, since containers inherit it. Treat leeway as a shock absorber for sub-second jitter, never as a substitute for synchronized clocks.
Diagnosing Skew in Practice
- Decode the failing token and note its
expandiatvalues as absolute times. - Compare against the verifying server's clock — run
date -uon that host. - If the server's "now" sits past
expby a margin smaller than the token's intended lifetime, you have skew, not a genuinely old token.
"Expired Before Issued" and the 'nbf'/'iat' Trap
A confusing relative of the expiry error is a token rejected as not-yet-valid. The nbf (not before) claim, RFC 7519 Section 4.1.5, defines the earliest moment a token may be accepted; iat (issued at), Section 4.1.6, records when it was minted. When the issuer's clock runs behind the verifier's, a brand-new token can carry an nbf or iat that appears to be in the future from the verifier's point of view, producing errors like "token used before issued." This is the mirror image of the expiry skew problem and has the same root cause and the same fix: synchronize clocks and apply a small symmetric leeway to nbf as well as exp. Decoding the token and reading all three time claims side by side — iat, nbf, exp — is the fastest way to see which boundary is being crossed.
Why UTC, Not Local Time, Matters Here
A subtle source of phantom expiry is timezone handling. NumericDate is defined relative to UTC, so a verifier that accidentally builds "now" from a local timezone — for example by parsing a naive datetime in a server running on US Eastern time — can be off by hours, not minutes, and no amount of leeway will rescue it. The symptom is distinctive: tokens are rejected (or accepted) by an offset that exactly matches a whole-hour timezone gap. Whenever you compute the current time for an exp comparison, derive it from an epoch-based, timezone-independent source such as Date.now() in JavaScript or time.time() in Python, never from a localized clock. Reading the token's exp as a UTC date in the decoder and comparing it to date -u on the host removes any ambiguity about which "now" is in play.

The Refresh Loop — When Expiry Is Working Correctly
Sometimes "token expired" is not a bug at all. Short-lived access tokens are supposed to expire, often within five to fifteen minutes, as a security control: a leaked token is useful only briefly. The problem in these cases is not the expiry but a broken refresh flow. If your access token expires every fifteen minutes and the user is forcibly logged out, the silent refresh against your authorization server is failing.
This commonly produces an infinite "refresh loop": the client detects expiry, requests a new token, the refresh itself fails (often because the refresh token has its own, longer exp that has also lapsed, or because of a misconfigured audience), and the cycle repeats. Decode both tokens. If the access token's short exp is by design but the refresh token's exp has passed, the user genuinely must re-authenticate. If the refresh token is still valid, the failure is in the refresh request — frequently a mismatched aud or a rotated signing key — not in expiry at all. Our companion article on decoding tokens and the deep-dive in JWT Tokens Explained walk through the access-token / refresh-token split that underlies this pattern.
A Step-by-Step Fix Workflow
When a "token expired" error appears, resist the urge to bump lifetimes blindly. Work the problem in order:
- Decode first. Paste the token into the JWT Decoder and read
expas a date. Is it actually in the past, or does it read as 1970 / the year 50,000? A nonsensical date is a units bug. - Check the unit. Confirm both issuer and verifier treat
expas seconds. Replace anyDate.now()comparison withMath.floor(Date.now()/1000). - Check the clocks. Run
date -uon the issuing and verifying hosts. Synchronize with NTP if they differ. - Check leeway. Ensure your library applies a modest skew tolerance (30–60s) per RFC 7519 §4.1.4 — but fix the clocks rather than inflating leeway.
- Check the refresh path. If expiry is by design, verify the refresh token's own
exp,aud, and signing key are valid. - Verify the signature. A token that fails verification for an unrelated reason can surface as a generic auth error mistaken for expiry; confirm the signature holds before blaming the clock.
Security Notes — Don't Fix Expiry the Wrong Way
The tempting "fix" for repeated expiry pain is to extend the access token's lifetime to hours or days. Resist it. A long-lived access token converts any leak into a durable credential an attacker can replay until it finally expires, and unlike a session it cannot be revoked instantly because a signed JWT stays valid until its exp regardless of server-side state. The OWASP guidance on session and token management is consistent on this point: keep access tokens short, push longevity into a separately protected refresh token, and never widen exp to paper over a broken refresh flow.
Equally important: never paste a live production token into a server-side online decoder, which transmits your active credential to a third party. A client-side tool that processes everything in the browser — like ours — keeps the token on your machine while you debug.
Conclusion
The JWT exp claim is unforgiving by design: RFC 7519 mandates that an expired token be rejected, so the answer is never to coax the verifier into accepting it but to understand why it reads as expired. In practice the cause is almost always one of four things — milliseconds confused for seconds, clock skew between hosts, a future-dated nbf/iat from a lagging issuer, or a refresh flow that is failing while expiry behaves exactly as intended. Each is diagnosable in seconds by decoding the token and reading its time claims as real dates. Keep the JWT Decoder & Inspector open while you debug, reach for the JSON Formatter when a payload is dense, and revisit JWT Tokens Explained for the full mental model. Everything stays in your browser, so even production tokens never leave your machine.