Cybersecurity / Networking

JWT Token Expired But Still Being Accepted

If a JWT with a clearly expired timestamp is still being accepted by your API, the token itself isn't the problem — your validation logic is either not checking the expiration claim at all, or is checking it incorrectly.

The Problem

A token issued with an exp claim in the past continues to authenticate successfully against protected endpoints, when it should be rejected with a 401 Unauthorized response instead.

{
  "sub": "user123",
  "iat": 1700000000,
  "exp": 1700003600
}
// exp timestamp is in the past, but requests still succeed

Why It Happens

JWT libraries typically verify the signature automatically, but expiration checking is a separate step that depends on how the library is used and configured. Common causes:

  • The code decodes and reads the token's claims without calling the library's actual verification function, which is where expiration checking normally happens — manually parsing the payload without verification skips this check entirely
  • The verification function is called, but with an option that explicitly disables expiration checking (some libraries offer this for specific testing scenarios, and it can accidentally end up in production code)
  • The server's clock is significantly out of sync with the time the token was issued, and depending on how strictly the library compares timestamps, a large clock skew can make an actually-expired token appear valid, or vice versa
  • The token doesn't include an exp claim at all — if the client generating tokens never set an expiration, there's nothing for the server to check against, and the token is valid indefinitely by design

The Fix

First, confirm expiration is actually part of your verification call, not just decoding. In most JWT libraries, decoding and verifying are separate functions — using the wrong one skips signature and expiration checks entirely:

// Decoding only - does NOT check expiration or signature
const payload = jwt.decode(token);

// Verifying - checks signature AND expiration by default
const payload = jwt.verify(token, secretKey);

Make sure your authentication middleware uses the verifying function, not just decode, for every protected request.

Check whether expiration checking was explicitly disabled somewhere in the verification options, which some libraries allow for specific use cases:

// This explicitly ignores expiration - remove unless intentional
jwt.verify(token, secretKey, { ignoreExpiration: true });

If this option exists in your codebase, it was likely added temporarily for testing and never removed — search for it across the codebase to make sure it isn't accidentally active in production paths.

Confirm your server's system clock is accurate, since a significant clock skew between the token issuer and the verifying server can cause otherwise-correct expiration logic to behave unexpectedly:

date
timedatectl status

If you're generating tokens yourself, confirm the exp claim is actually being set at token creation time, with a sensible short lifetime for access tokens:

const token = jwt.sign(payload, secretKey, { expiresIn: '15m' });

Still Not Working?

If expiration is correctly checked and still not being enforced, check whether your application has a caching layer sitting between the token validation and the actual request handling — for example, caching "this token is valid" results for a period of time to avoid re-verifying on every request. If that cache doesn't also expire at or before the token's own expiration time, a request made just before expiration can result in a cached "valid" result being reused for requests made after the token has actually expired.

It's also worth checking whether multiple layers of your infrastructure each independently validate the token, and only some of them are correctly configured. A common setup has an API gateway performing an initial token check, followed by the application itself performing a second check — if the gateway's validation logic was updated to properly check expiration but the application's own internal check wasn't (or vice versa), a token could pass one layer and still reach protected logic through a path that skips the fixed validation entirely.

Finally, if you're dealing with a long-lived refresh token system alongside short-lived access tokens, confirm you're actually testing the right token type. Refresh tokens are often intentionally longer-lived and used only to obtain new access tokens, not to authenticate requests directly — if application code mistakenly accepts a refresh token as if it were an access token, it can appear to "never expire" simply because it's checking the wrong claim's expiration window, or because refresh tokens in your system are deliberately valid for a much longer period by design.