All posts

JWT as Oauth2 Access Tokens & Refresh Tokens— Invalidation: The Awkward Reality

I’ve touched on the topic of using JWT as an Access Token on several previous blog posts:

OAuth2 is one of those technologies that sounds straightforward until you actually implement it.

The sales pitch goes something like_, “_Use access tokens and refresh tokens. It’s secure. It’s scalable. It’s stateless.”

Then, somebody asks_, “_How do we log a user out immediately?”

And suddenly every architect in the room develops an intense interest in the ceiling tiles.

Welcome to the wonderful world of token invalidation.

The Dream

JWTs (JSON Web Tokens) are fantastic.

They contain claims.

They are cryptographically signed. They can be encrypted, optionally.

They can be validated without calling a database.

They scale beautifully.

A resource server can look at a token, verify the signature, check the expiration time, and make an authorization decision without contacting anybody else.

It’s elegant.

It’s efficient.

It’s what conference speakers describe while smiling confidently.

The Problem

Let’s say Alice logs in.

The authorization server issues:

  • An access token
  • A refresh token

Maybe, both are JWTs (don’t have to be, this is an implementation decision).

Life is good.

Then, Alice’s laptop is stolen.

Or, Alice changes her password.

Or, Alice gets fired.

Or, Alice discovers that clicking every email attachment is not a sustainable cybersecurity strategy.

The security team now wants to revoke access immediately.

This seems reasonable.

Unfortunately, JWTs have other plans.

The Access Token Doesn’t Care

A JWT is essentially a signed statement.

It says_,_ “At the moment I was issued, the authorization server approved this user.”

The resource server verifies the signature.

If the signature is valid and the token hasn’t expired:

Access is granted.

Notice what’s missing.

The resource server does not as_k_ if this token still okay?

It only asks was this token okay when it was issued?

This distinction causes endless confusion.

A JWT cannot magically learn that Alice’s account was disabled thirty seconds ago.

It’s a string of characters.

Not a psychic.

The Stateless Trap

Developers often hear JWTs are stateless.

And, everyone nods enthusiastically.

  • Statelessness sounds modern.
  • Statelessness sounds scalable.
  • Statelessness sounds like something a cloud architect would put on a PowerPoint slide.

But, statelessness comes with a tradeoff.

If no server-side state exists, then nothing exists to revoke.

If every server independently validates a JWT without consulting a central authority, how would those servers know a token has been revoked?

It wouldn’t.

Short-Lived Access Tokens

The most common solution is surprisingly simple.

Make access tokens expire quickly.

For example:

  • Access token: 5 minutes
  • Refresh token: 30 days

Now the maximum damage window is approximately five minutes.

If Alice gets fired:

  1. Revoke the refresh token.
  2. Wait for the access token to expire.

Problem solved.

Mostly.

This approach is so common that many OAuth2 systems simply accept the fact that immediate revocation of access tokens doesn’t exist.

They rely on short expiration times instead.

This is the solution nine out of ten of my clients have used.

Refresh Tokens: The Real Control Point

Refresh tokens are where most of the control actually lives.

When the access token expires:

  • The client presents the refresh token.
  • The authorization server decides whether to issue a new access token.
  • If the refresh token has been revoked:

No new access token is issued.

Session terminated.

The refresh token becomes the choke point for the entire authentication flow.

The “JWT Refresh Token” Problem

Things get interesting when refresh tokens themselves are JWTs.

Many developers assume this means no database is required.

This is usually where future incidents begin.

Suppose a refresh token is valid for 30 days.

If it’s a self-contained JWT and no server-side state exists:

How do you revoke it?

You don’t.

At least not immediately.

The token remains valid until expiration.

Which means a stolen refresh token could potentially continue generating new access tokens for weeks.

Security teams tend to react poorly when informed of this design detail.

I’ve never seen a production deployment do this myself; none of the major IdP vendors do this. One usually sees this from teams that decided to roll their own identity platform.

The Blacklist Approach

One solution is maintaining a revocation list.

When a token is revoked, store its identifier in a database.

Whenever a token arrives, check whether it appears on the blacklist.

Simple.

Effective.

Also, hilariously incompatible with the original promise of statelessness.

Congratulations.

You’ve reinvented server-side state…

Token Versioning

A more elegant approach involves token versioning.

Each user account contains something like:

token_version = 7

Tokens include:

{  "sub": "alice",  "token_version": 7}

When Alice’s account must be invalidated:

token_version = 8

All previously issued tokens instantly become invalid.

The resource server checks the version against a central store.

This works very well.

It also requires a central store.

Once again, statelessness quietly exits the building.

Key Rotation: The Nuclear Option

Another option is rotating signing keys.

If the signing key changes:

All tokens signed with the old key become invalid.

Problem solved.

Also every user on the planet gets logged out simultaneously.

This tends to be unpopular.

It is the authentication equivalent of fixing a leaky faucet by demolishing the house.

This also requires that every JWT verifier (Resource Server) be correctly retrieving the latest published signer certificate(s) (JWKS Endpoint) periodically (bordering on often). So, really, just moving the problem downstream.

OAuth2 Introspection

Some systems abandon pure JWT validation entirely.

Instead of trusting tokens locally, the resource server asks the authorization server_,_ “Is this token still active?”

This is called Token Introspection (RFC7662).

Revocation becomes immediate.

Security teams are happy.

Network latency increases; overall, application response time increases.

Architects become less happy.

Everything in engineering is a tradeoff.

The Secret Nobody Likes To Admit

The dirty little secret of JWT-based authentication is that true statelessness and immediate revocation are largely incompatible goals.

You can have:

  • Fully stateless validation

or

  • Immediate token invalidation

Pick one.

You generally cannot have both.

The moment you require immediate revocation, some form of centralized state inevitably appears.

Whether it’s:

  • A blacklist
  • Token versioning
  • Introspection
  • Session storage
  • Refresh token tracking

State always sneaks back into the design.

It’s like glitter.

Once introduced, you’re never completely rid of it.

What Most Production Systems Actually Do

Most mature OAuth2 implementations use a practical compromise:

  • Short-lived JWT access tokens (5–15 minutes)
  • Server-side refresh token storage
  • Refresh token rotation
  • Refresh token revocation support

This provides:

  • Good scalability
  • Reasonable security
  • Practical logout behavior
  • Manageable operational complexity

Most importantly, it avoids pretending that JWTs possess magical revocation powers.

Because they don’t.

They’re signed documents. Not telepathic security guards.

Summary

JWTs are fantastic for scalable authorization.

They reduce database lookups.

They improve performance.

They simplify distributed architectures.

But they also introduce a fundamental reality: a signed token cannot un-sign itself.

Once issued, a JWT remains valid until it expires, its signing key is revoked, or some external state says otherwise.

And that’s the great irony of OAuth2 token invalidation.

The technology celebrated for eliminating server-side state usually ends up requiring just enough server-side state to make revocation work.

Computer science is full of these little jokes.

This one just happens to be running in production.

Notes

  • AI / GenAI / ChatGPT / etc were not used to generate the text of this article.
  • ChatGPT was used to generate the images.
  • I used em dashes in my writing before the current GenAI wave was a thing. Not planning on changing now.
  • Names have been changed to protect the guilty.
  • None of the hostnames or users used in examples actually exist.
  • Feel free to post any comments or suggestions below.

Originally published on Medium.