Home › Security › JWT Expired Still Accepted: Clock Skew Fix Guide
Intermediate 5 min · September 23, 2026

JWT Expired Still Accepted: Clock Skew Fix Guide

Expired JWTs get accepted through missing exp checks and loose leeway.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Written from production experience, not tutorials.

Follow
✓ Production
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 12 min
  • ✓JWT header, payload, and exp basics
  • ✓How API auth headers work
  • ✓Refresh token concepts
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • A token accepted after expiry usually means hand-rolled code never validated exp, or leeway is set far too wide
  • Clock skew between issuers and verifiers is real but small; cap leeway at 60 to 120 seconds, not minutes or hours
  • Always validate exp, iat, iss, and aud after the signature passes, using the library's built-in claim checks
  • Short-lived access tokens (5 to 15 minutes) plus rotating refresh tokens shrink replay and leak windows fast
  • Log accept-after-expiry events per service so silent misconfiguration can't hide for months
✦ Definition~90s read
What is JWT Token Expired but Still Accepted?

JWT expiry rests on three numeric claims: exp marks when the token dies, iat marks when it was born, and nbf optionally marks when it becomes valid. Verification should reject any token where the current time has passed exp, allowing only a small leeway for clock differences between the issuer and the verifier.

★
Think of a JWT like a concert wristband stamped with an expiry time.

Libraries like PyJWT enforce this when configured with the right options; hand-rolled code that splits segments and checks signatures by hand typically skips it entirely.

Clock skew is the legitimate reason for leeway. Two servers can disagree by seconds because NTP hasn't converged, virtual machines pause, or containers inherit a stale clock. A tolerance of 60 to 120 seconds absorbs that drift without meaningful risk.

The trouble starts when teams set leeway to 10 minutes, an hour, or effectively infinity to silence sporadic failures, turning expiry into decoration. Each extra minute extends the replay window for every stolen token.

The durable architecture is short-lived access plus refresh. Access tokens carry a 5 to 15 minute lifetime and ride each API call; refresh tokens live longer, are stored securely, rotate on use, and can be revoked. When access dies quickly, a leaked token is useful for minutes, while legitimate users glide through silent refresh.

Revocation lists then only need to track refresh tokens, which keeps them small and fast.

Detection is a behavior test, not a code stare: mint a token, wait past expiry or forge the clock, and confirm a 401. Services that answer 200 have the bug regardless of what their config claims. Pair that test with per-service logging of post-expiry accepts, and silent misconfiguration has nowhere to hide.

Plain-English First

Think of a JWT like a concert wristband stamped with an expiry time. The bouncer should check the stamp under good light and turn away old bands. But if the bouncer never looks at the stamp, or allows bands from last week because their watch might be off, anyone keeps getting in. That's this bug. The fix is checking every stamp with a good clock, allowing only a minute of wiggle room for watch differences, and handing out wristbands that expire quickly so lost ones stop working fast.

Nothing erodes trust in authentication like learning that expired tokens still open doors. Logout feels broken, offboarding stops working, and leaked tokens stay useful far past their stamped lifetime. Teams usually discover this during an audit or after a stolen token gets replayed days later, when the damage is already done.

Two causes explain nearly every case. Hand-rolled verification decodes the payload and checks the signature but never looks at exp at all. Or the library is configured with a generous leeway meant to cover clock skew, stretched to tens of minutes by someone chasing away occasional failures. Both leave the door open long after it should have closed.

The fix combines strict validation with short lifetimes. Validate exp and the surrounding claims on every request, cap skew tolerance near a minute, and issue access tokens that live for minutes while refresh tokens handle continuity. Clock discipline with NTP keeps servers agreeing on time.

This guide shows how to confirm the hole, tighten validation without breaking honest clients, and build the access-plus-refresh pattern that makes expiry meaningful again.

Why Expired Tokens Get Accepted: the Two Usual Gaps

Accept-after-expiry almost always traces to one of two gaps. The first is hand-rolled verification that decodes the payload, checks the signature, reads the role, and never glances at exp. It feels complete because every line looks security-related, yet the lifetime check is simply absent. Any signed token then works forever, including tokens from deactivated users.

The second gap is library verification with expiry disabled or leeway stretched beyond reason. Someone fighting sporadic failures sets leeway to 600 seconds or flips verify_exp off to calm the dashboard, and the change survives because nothing tests stale tokens. The stamped 30-minute lifetime becomes 40 minutes or infinity while the config comment still says clock skew.

Both gaps share a root habit: trusting issuance instead of verifying consumption. Setting exp at login is only half the job; each consumer must enforce it on every request. Audit the consume path, not the login path, when this bug is suspected.

Confirm with behavior, not reading. Mint a short-lived token, let it expire, and replay it. A 200 means the gap is real regardless of what anyone remembers about the config. That single test converts a vague suspicion into a concrete failing case the team can fix.

stale_replay_test.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import time
import jwt

SECRET = 'test-only-secret'

def mint(lifetime_s: int) -> str:
    now = int(time.time())
    return jwt.encode({'sub': 'ana', 'iat': now, 'exp': now + lifetime_s}, SECRET, algorithm='HS256')

def strict_verify(token: str) -> bool:
    try:
        jwt.decode(token, SECRET, algorithms=['HS256'], leeway=90, options={'require': ['exp', 'sub']})
        return True
    except jwt.InvalidTokenError:
        return False

tok = mint(1)
time.sleep(2)
# Must be False: token is stale even inside small leeway.
print('stale accepted?', strict_verify(tok))
print('fresh accepted?', strict_verify(mint(600)))
⚠ Replay Tests Use Staging Only
Mint and replay stale tokens against disposable staging consumers. Never replay real user tokens or production credentials.
📊 Production Insight
The 26-day incident above combined both gaps: one consumer skipped exp entirely while another allowed 600 seconds of grace. Symptom: stale tokens returning 200 with valid-looking configs. Rule: replay a stale token per consumer; behavior beats config memory.
🎯 Key Takeaway
Missing exp checks or oversized leeway keep dead tokens alive. Prove the gap with a stale replay, then fix the consume path.

Clock Skew Is Real but Small: NTP and Sane Leeway

Servers genuinely disagree about time. Virtual machines pause during migration, containers inherit host clocks, and NTP takes minutes to converge after boot. A token minted at second 100 on the issuer may arrive at a verifier that believes it is second 97. Without tolerance, honest tokens fail at the edges and engineers get paged for phantom outages.

The answer is small, explicit leeway plus healthy clocks. Cap tolerance at 60 to 120 seconds, which absorbs real drift while adding negligible replay risk. Then fix the clocks themselves: run chrony or systemd-timesyncd on every host, monitor offset with chronyc tracking, and block deploys on hosts with unsynchronized clocks. Containers should inherit from synced hosts, not carry their own drift.

Resist the urge to widen leeway when failures appear. A 10-minute tolerance that silences alerts also grants every stolen token 10 bonus minutes on every request. Investigate the skew first with date comparisons and NTP status across issuer and verifier; the fix is usually one unsynced host, not a config change.

Document the chosen value once and enforce it centrally. A shared verify helper with leeway pinned at 90 seconds beats five services with five opinions. Alert when any consumer overrides it, since widening tolerance is a security decision disguised as reliability tuning.

clock_skew_check.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/usr/bin/env bash
set -euo pipefail
ISSUER_HOST="${1:?usage: clock_skew_check.sh <issuer-host>}"
echo "== local clock =="
date -u '+local UTC: %Y-%m-%dT%H:%M:%SZ'
if command -v chronyc >/dev/null 2>&1; then
  chronyc tracking | grep -E 'System time|NTP server' || true
elif command -v ntpq >/dev/null 2>&1; then
  ntpq -p | head -6
else
  echo 'no chronyc/ntpq found; confirm NTP via your platform agent'
fi
echo '== issuer clock (HTTP Date header) =='
curl -sI "https://$ISSUER_HOST/.well-known/jwks.json" | grep -i '^date:' || echo 'no Date header returned'
echo 'Drift over 120s means fix NTP, not widen leeway.'
📊 Production Insight
The secondary service above hid behind 600 seconds of leeway that nobody questioned for months. Symptom: sporadic edge failures cured by widening tolerance. Rule: fix NTP first, cap leeway near 90 seconds, and alert on overrides.
🎯 Key Takeaway
Allow about a minute for drift and keep clocks synced with NTP. Wide leeway trades a small reliability gain for a large replay window.

Strict Claim Validation: exp, iat, iss, and aud Together

Expiry alone isn't enough; the surrounding claims stop token misuse across services and time. Validate exp so dead tokens die, iat to catch tokens minted in the future beyond skew, iss to confirm the expected login service issued it, and aud to confirm it was meant for your API. A token minted for the mobile API shouldn't open the admin panel just because the signature verifies.

Libraries do this well when asked. PyJWT's decode with require options and audience checks, java-jwt's acceptExpiresAt plus acceptIssuer, and jose equivalents all enforce the set in a few lines. The failure mode is always the same: defaults left permissive, audience unset because two services share tokens, or issuer unchecked after a migration added a second login path.

Standardize one strict helper per language in your repo and route every consumer through it. The helper pins algorithms, requires the claim set, caps leeway, and rejects unknown kids. New services import it instead of writing their own, which ends hand-rolled drift permanently.

Test the helper negatively: expired tokens, future-dated tokens, wrong issuer, and wrong audience must all receive 401s. Those four tests run in milliseconds and catch the exact misconfigurations that cause silent acceptance in production.

strict_claims.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import jwt

SECRET = 'test-only-secret'
ISSUER = 'https://auth.example.com'

def verify_api_token(token: str) -> dict:
    return jwt.decode(
        token,
        SECRET,
        algorithms=['HS256'],
        issuer=ISSUER,
        audience='billing-api',
        leeway=90,
        options={'require': ['exp', 'iat', 'iss', 'aud', 'sub']},
    )

if __name__ == '__main__':
    import sys
    try:
        print(verify_api_token(sys.argv[1])['sub'])
    except jwt.InvalidTokenError as exc:
        print(f'401 unauthorized: {exc}')
📊 Production Insight
Deactivated accounts stayed readable above because one consumer checked role but not issuer or expiry. Symptom: tokens working across services they were never meant for. Rule: one strict helper requiring the full claim set on every consumer.
🎯 Key Takeaway
Validate the full claim set after the signature passes. A shared strict helper keeps every consumer honest with four fast negative tests.

Short Access Plus Refresh: Making Expiry Mean Something

Long-lived access tokens make every leak a long incident. A 24-hour token stolen at 9 AM works until tomorrow morning, surviving logout, password change, and even deactivation when expiry isn't checked. Shortening access to 5 to 15 minutes flips the math: the same leak buys minutes, while legitimate users never notice because refresh happens silently.

The refresh side carries the continuity. Refresh tokens live longer, travel only to the token endpoint, are stored securely (httpOnly cookies for browsers, secure storage for apps), rotate on each use, and revoke as a family on logout or suspicion. The server tracks refresh state, so revocation actually works, while stateless access tokens stay fast and small.

Rollout needs care around concurrency and reuse. Mobile apps with parallel requests can race refresh; accept the previous refresh briefly or serialize renewal. Treat refresh reuse as a theft signal: invalidate the whole family when a used token reappears, since only a cloned token could do that.

Measure the result directly. Plot access-token age at use time; after migration nearly every accepted token should be minutes old. The nightly stale-replay test then guards the property forever: any consumer answering 200 to yesterday's token pages immediately.

refresh_flow.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import secrets
import time

ACCESS_LIFE = 600  # 10 minutes
REFRESH_LIFE = 7 * 86400
_refresh_store = {}

def issue(user: str) -> dict:
    now = int(time.time())
    refresh = secrets.token_urlsafe(32)
    _refresh_store[refresh] = {'user': user, 'expires': now + REFRESH_LIFE, 'family': secrets.token_hex(8)}
    return {'access_exp': now + ACCESS_LIFE, 'refresh': refresh}

def refresh(old: str) -> dict | None:
    now = int(time.time())
    meta = _refresh_store.pop(old, None)  # rotation: single use
    if not meta or meta['expires'] < now:
        return None
    return issue(meta['user'])

pair = issue('ana')
print('access valid for (s):', ACCESS_LIFE)
print('rotated ok?', refresh(pair['refresh']) is not None)
print('reuse blocked?', refresh(pair['refresh']) is None)
📊 Production Insight
Ten-minute access would have cut the 26-day exposure above to minutes on the strict consumers. Symptom: access tokens living for hours or days for convenience. Rule: 5 to 15 minute access with rotating, revocable refresh.
🎯 Key Takeaway
Short access limits leak damage; revocable rotating refresh preserves UX. Reuse detection turns cloned refresh tokens into instant lockouts.

Finding Every Consumer That Skips the Check

JWT verification sprawls. The web API checks strictly, the billing worker decodes loosely, and a legacy cron job splits segments by hand. Each consumer makes its own decision about exp, so fleet-wide safety needs an inventory, not assumptions. List every service that accepts tokens before changing anything.

Hunt mechanically. Search for decode, verify, and split calls across repos, flag hand-rolled segment parsing, and record each consumer's leeway and required claims in one table. The stale-replay test then runs against each entry: mint, expire, replay, record 401 or 200. The resulting map shows exactly which services need the strict helper.

Migrate in waves with monitoring. Convert the highest-traffic consumer first, watch 401 rates for honest-client breakage, then proceed down the list. Keep a dashboard of post-expiry accept events per service; it should read zero everywhere and page otherwise.

Lock the win with standards. New services import the shared verifier, CI runs the four negative claim tests, and config changes to leeway need security review. The inventory stays current because the nightly replay proves it, not because someone remembers to update a wiki. Record the decision per consumer so future migrations inherit the map instead of rebuilding it.

📊 Production Insight
Three consumers with three opinions on exp caused the month-long exposure. Symptom: inconsistent 401 behavior across services for the same stale token. Rule: inventory every consumer, replay against each, and standardize on one helper.
🎯 Key Takeaway
Map every token consumer and replay stale tokens against each. Migrate to one strict helper and prove it nightly.

Monitoring and Offboarding: Proving Dead Means Dead

Expiry enforcement pays off in offboarding and incident response. When an employee leaves or a password changes, short access plus revoked refresh ends sessions within minutes instead of someday. But only monitoring proves it: log token age at use, alert on post-expiry accepts, and chart the maximum accepted age per service daily.

Build the logout story explicitly. Access tokens can't be individually revoked at scale, so logout revokes the refresh family and lets access die naturally within minutes. Document that window honestly for support and auditors rather than claiming instant global logout. For high-risk events like role removal, pair revocation with a short deny-list of recent access tokens.

Run the numbers after migration. The incident team above watched accepted-token age collapse from 26 days to under 10 minutes on deploy day. Deactivated-account access attempts started returning 401s immediately, and the quarterly audit replay became a non-event.

Keep the evidence flowing: nightly stale replays, per-service accept-age dashboards, and alerts on unknown-kid or future-dated spikes. Dead meaning dead is a property you demonstrate continuously, not a box you check once. Evidence beats memory during audits.

📊 Production Insight
The quarterly audit caught what monitoring should have: 1,900 stale accepts over a month. Symptom: no dashboard for accepted-token age. Rule: chart max accepted age per service and page the nightly replay on any 200.
🎯 Key Takeaway
Monitor accepted-token age and replay staleness nightly. Short access plus refresh revocation makes offboarding real within minutes.
● Production incidentPOST-MORTEMseverity: high

Leaked Tokens Worked 26 Days Past Their Stamped Expiry

Symptom
During a quarterly access review, an engineer replayed a token captured 26 days earlier and received a full 200 OK with customer data. Logout hadn't helped affected users because their old tokens never died. Over the prior month, about 1,900 tokens older than their 30-minute stamped lifetime had successfully called the API, including 34 belonging to deactivated accounts.
Assumption
The team assumed their JWT middleware validated expiry because the login service set exp correctly on every token. They also assumed a 10-minute leeway they'd configured for clock skew was harmless, not realizing a second hand-rolled consumer ignored exp entirely. Everyone believed short stamped lifetimes equaled short real lifetimes without ever testing a stale token.
Root cause
The main API used a hand-written verifier that checked the signature and role claim but never read exp, so any signed token worked forever. A secondary service used a library with leeway stretched to 600 seconds, adding its own 10-minute grace. Together they kept roughly 1,900 stale tokens usable, and deactivated accounts stayed readable for up to 26 days because no revocation list existed for access tokens.
Fix
The team replaced the hand-rolled verifier with the library's strict decode requiring exp, capped leeway at 90 seconds, and redeployed all three API consumers in one afternoon. Access lifetime dropped to 10 minutes with rotating refresh tokens, and logout now revokes the refresh family. All 1,900 stale tokens died on deploy, the 34 deactivated accounts were re-verified as locked, and a nightly stale-token replay test now pages on any 200.
Key lesson
  • Stamped expiry means nothing without verification. A nightly job that replays a stale token against every consumer proves exp is checked, which config reviews never do.
  • Hand-rolled JWT code is where expiry goes to die. Use the library's claim validation on every consumer, and delete custom verifiers instead of patching them.
  • Short access plus revocable refresh beats long access every time. Ten-minute access tokens make leaks boring, while refresh rotation keeps users signed in cleanly.
Production debug guideFive checks that prove whether expiry is enforced and where the gap lives.5 entries
Symptom · 01
A token older than its exp still returns 200 on API calls
→
Fix
Prove it in staging: mint a 60-second token, wait 90 seconds, and replay it expecting a 401. If it returns 200, open the verifier and check whether exp is validated at all. Replace hand-rolled decodes with strict library verification requiring exp, then rerun the replay until it fails.
Symptom · 02
Fresh tokens intermittently fail right after issuance across services
→
Fix
Measure real skew before widening leeway: compare date -u output and NTP status (chronyc tracking or ntpq -p) on issuer and verifier. Fix NTP sync and container clock inheritance first. Cap leeway at 60 to 120 seconds in config, since larger values mask the sync problem while extending replay windows.
Symptom · 03
Leeway is set to many minutes or effectively skips expiry
→
Fix
Read the verify options in each consumer: look for leeway values over 120 seconds or verify_exp set false. Tighten to 90 seconds and re-enable exp checks, then run the stale-replay test per service. Alert on any config change that widens leeway past the cap.
Symptom · 04
Logout and deactivation don't stop old access tokens
→
Fix
Shorten access lifetime to 5 to 15 minutes and move continuity to rotating refresh tokens that logout revokes. Confirm a logged-out refresh fails and in-flight access dies within minutes. Re-test the 34-style deactivated-account case until stale access returns 401.
Symptom · 05
Unsure which consumers enforce expiry across a fleet
→
Fix
Run the stale-token replay against every consumer in staging and record 401 versus 200 per service. Standardize all of them on one strict verify helper with required claims. Schedule the replay nightly in CI so new consumers can't silently regress.
Expired-Accepted Causes Compared
Root CauseHow to ConfirmFixPrevention
Hand-rolled verifier skips expStale replay returns 200Strict library decode requiring expNightly stale replay in CI
Leeway stretched to minutesConfig shows 600s; edges passCap leeway at 60 to 120 secondsAlert on leeway overrides
Long-lived access tokensAccepted age spans days10-minute access plus refreshChart accepted-token age
Unknown consumer fleetServices disagree on stale tokensOne shared strict helperInventory plus per-service replay
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
stale_replay_test.pySECRET = 'test-only-secret'Why Expired Tokens Get Accepted
clock_skew_check.shset -euo pipefailClock Skew Is Real but Small
strict_claims.pySECRET = 'test-only-secret'Strict Claim Validation
refresh_flow.pyACCESS_LIFE = 600 # 10 minutesShort Access Plus Refresh

Key takeaways

1
Accept-after-expiry comes from skipped exp checks or oversized leeway; replay proves it.
2
Cap skew tolerance near 90 seconds and keep clocks synced with NTP everywhere.
3
Require exp, iat, iss, and aud together through one shared strict verify helper.
4
Short access plus rotating refresh shrinks leaks to minutes while preserving sessions.
5
Inventory every token consumer; each one decides expiry independently.
6
Dashboard accepted-token age and replay staleness nightly so regressions page fast.

Common mistakes to avoid

5 patterns
×

Writing a custom JWT verifier instead of using the library

Symptom
Signature passes but exp, aud, and iss go unchecked, so stale tokens work indefinitely.
Fix
Delete custom verifiers and route every consumer through one strict library helper.
×

Setting leeway to 10 minutes to silence edge failures

Symptom
Honest edge cases pass, but every stolen token gains 10 bonus minutes per request.
Fix
Cap leeway near 90 seconds and fix the unsynced clocks causing the edge failures.
×

Issuing day-long access tokens for convenience

Symptom
Leaks stay usable through logout and deactivation, turning small thefts into long incidents.
Fix
Use 5 to 15 minute access with rotating refresh tokens that logout revokes.
×

Skipping audience and issuer checks across services

Symptom
Tokens minted for one API open another, widening every leak's blast radius.
Fix
Require matching iss and aud in the shared helper and test wrong-service tokens.
×

Assuming issuance config proves consumer enforcement

Symptom
Login sets exp correctly while three consumers ignore it, and nobody tests the gap.
Fix
Replay stale tokens against each consumer nightly and dashboard post-expiry accepts.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why would an expired JWT still be accepted?
Q02JUNIOR
How much clock-skew leeway is reasonable?
Q03SENIOR
Access tokens live 24 hours. What's your migration?
Q04SENIOR
How do you find all consumers that skip expiry?
Q05SENIOR
Logout must work but access is stateless. How?
Q01 of 05JUNIOR

Why would an expired JWT still be accepted?

ANSWER
The consumer never validates exp, or leeway is set so wide the token stays inside grace. Hand-rolled verifiers skip the check entirely. A stale replay returning 200 proves it regardless of config claims.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Is a 10-minute leeway ever acceptable?
02
Can I revoke stateless access tokens?
03
Do refresh tokens need rotation?
04
How do containers get clock skew?
05
Should mobile apps use shorter or longer access?
06
What proves expiry works after the fix?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Written from production experience, not tutorials.

Follow
✓ Verified
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
🔥

That's Auth. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
JWT Signature Verification Failed
3 / 5 · Auth
Next
SSL Certificate Verify Failed: Unable to Get Local Issuer
→