← Back to Blog

Your JWT Is Not a Session

JWTs cannot be revoked, permissions inside them go stale, and clocks drift. The failure modes that appear when you treat a signed token like a session.

The security team asked how the application handles a compromised account. Standard procedure: detect the compromise, reset the password, revoke all active sessions. An engineer found the logout-all endpoint. It set a flag in the database marking the account as revoked. The problem was that the application used JWTs for authentication, and the validation code checked only the token's signature and expiry. The flag was never consulted. Tokens issued before the reset continued to work for another 23 hours. The compromised account stayed accessible for a full day after the security team believed they had contained it.

This is the failure mode that appears when you treat a JWT like a session but manage it like a static credential.

What a JWT Actually Is

A JWT is a signed JSON payload with three parts. A header carries the algorithm and token type. A payload carries claims (subject, expiry, issued-at). A signature is computed over both, using a secret or private key. Anyone with the corresponding key or public key can verify that the payload has not been tampered with since it was issued.

That is the entire guarantee. The token is valid if the signature checks out and the expiry has not passed. Nothing else. No server-side state is consulted. No database row. No cache. The token is self-contained.

This is the property that makes JWTs attractive. You can distribute validation across many services without them needing to share a session store. It is also the property that makes revocation impossible without adding that server-side state back in.

You Cannot Revoke a JWT

When a user logs out, the server has no record to delete. The token still exists, signed correctly, with a valid expiry. If someone intercepts it or the user's device is stolen after logout, the token works until it expires.

The workarounds all reintroduce state:

  • Token blacklist. Store revoked JTI (JWT ID) values in a database or cache. Check on every request. Now you have a session store. The JWT's stateless advantage is gone, and you have added the latency of a cache lookup on every request.
  • Short expiry. Issue tokens that expire in 5-15 minutes. Compromise window is short. Now users need to reauthenticate frequently, or you implement refresh tokens, which bring their own problems.
  • Version counter. Store a token version per user. Put the version in the JWT. On each request, check that the token's version matches the database. Another database read per request.

None of these are wrong. They are deliberate trade-offs. The mistake is not choosing one of them. The mistake is issuing 24-hour JWTs and assuming that logout means the token stops working.

Clock Skew

The exp claim is a Unix timestamp. The library validates it by comparing it to the server's current time. If the issuing server and the validating server have clocks that differ by more than a few seconds, tokens expire at the wrong time from the validator's perspective.

In practice, servers in the same infrastructure have NTP-synchronized clocks that stay within a second or two of each other. But microservices under load, containers that have been running for weeks, or services in different regions can drift further. A token issued at 14:00:00 with a 5-minute expiry that the validator receives at 14:04:59 on a clock that is 2 seconds ahead will be rejected. The request fails with no useful error. Users retry. The retry also fails because the clock is still ahead.

Most JWT libraries have a leeway or clockSkew parameter that allows a few seconds of tolerance. Set it. The default in most libraries is zero.

Algorithm Confusion

The JWT header includes an alg field that specifies how the signature was computed. RS256 uses an asymmetric key pair: the issuer signs with a private key, and validators verify with the public key. HS256 uses a shared secret: both sides use the same key.

Here is how the attack runs. An application is configured to use RS256. By definition, the public key is public. An attacker grabs a valid token, flips the alg field in the header to HS256, modifies the payload, and signs the whole thing with the application's own public key. A naive validator that reads the alg field from the token and applies it will try to verify the HS256 signature using what it treats as the shared secret, which is actually the RS256 public key - and the signature verifies correctly because the attacker signed with that same key.

The fix is to not trust the alg field in the token. Configure your validator with the expected algorithm explicitly and reject any token whose header specifies a different one. The alg: none variant of this attack is even simpler: some libraries, when they see alg: none, skip signature verification entirely. A correctly configured library rejects this. A misconfigured one accepts anything.

Stale Permissions

JWTs often carry roles and permissions in the payload: "roles": ["admin", "billing"]. This lets downstream services check permissions without a database call. Those permissions are frozen at the time the token was issued.

If you remove a user's admin role, their current token still contains "roles": ["admin"] until it expires. If your tokens live for 24 hours, the user retains admin access for up to 24 hours after the role was revoked. If you are putting permissions in the token, short expiry is not optional. It is the only mechanism you have for bounding how long stale permissions stay valid.

The Refresh Token Pattern

Refresh tokens are the standard response to short-lived access tokens: issue a short-lived JWT (5-15 minutes) for API calls and a longer-lived opaque refresh token (days or weeks) that the client uses to get a new access token when the current one expires.

State is back, correctly this time. Refresh tokens live server-side. They can be revoked. The trade-off is that revocation only prevents the client from getting new access tokens. The current access token remains valid until it expires. For a 15-minute access token, that is a 15-minute window after revocation where the token still works.

Refresh token rotation - issuing a new refresh token every time one is used and invalidating the old one - limits the impact of a stolen refresh token. If the stolen token is used, the legitimate client's next refresh attempt fails because its token was already rotated. The server detects the reuse and can revoke the entire refresh token family. This requires storing refresh tokens in a database with enough metadata to detect reuse.

What to Actually Do

The choices that matter:

  • Keep access tokens short. Fifteen minutes is common. Anything over an hour is hard to justify if the application has any revocation requirement.
  • Do not put long-lived permissions in the token. Put only the user ID. Fetch roles from the database or a fast cache on each request if those roles need to be current. Add roles to the token only if you are willing to let them go stale for the token's full lifetime.
  • Pin the expected algorithm in your validator configuration. Do not read it from the token header.
  • Set clock skew leeway. Two to five seconds covers most real drift. Zero is wrong.
  • If you need true revocation, use a token blacklist keyed on the JWT's jti claim, checked in a fast cache. Accept the latency. The alternative is pretending revocation works when it does not.

JWT is a good format. The problems are not with the format. They are with the assumptions that accumulate around it: that logout means the token stops working, that permissions are current, that clocks agree, that the alg field can be trusted. Each assumption is individually small and collectively dangerous. Check your token lifetime, your algorithm configuration, and your revocation story. One of the three is probably wrong.

Share
X LinkedIn HN
UI

Umur Inan

Principal Software Engineer

Backend engineer focused on JVM systems, distributed architecture, and the failure modes that only show up in production. I write about what I learn building and breaking things at scale.

👁 0 5 min read

Comments (0)