JWT

JWTs vs Opaque Tokens: Stop Choosing Based on Vibes

The real trade-off is where authorization state lives and how fast it can change. A decision framework covering revocation latency, introspection, hybrid designs, and the failure modes each choice hands you.

Emilian GheoneaJuly 27, 20268 min read

The JWT-versus-sessions argument has been running for a decade and generates more heat than insight, largely because both sides are arguing about the wrong axis. It gets framed as stateless versus stateful, or scalable versus simple, and neither framing predicts which one will hurt you.

The question that actually matters is: when a fact about a user changes, how long until every part of your system acts on it?

Everything else — performance, size, complexity — follows from where you land on that.

The two designs

An opaque token is a random string with no internal meaning. It is a database key. The recipient looks it up to learn anything at all.

tok_9fK2mQ7xR4nP8vL1bT6wY3zA5cD0eG

A JWT carries its claims inside itself, signed. The recipient verifies the signature and reads the payload without asking anyone.

{ "sub": "user_123", "org": "org_acme", "role": "admin", "exp": 1720000900 }

The difference is not really stateless versus stateful, because the state exists either way — a JWT's claims were read from a database at issuance. The difference is when that read happened. An opaque token reads at use time. A JWT reads at issue time and carries a snapshot forward.

That is the whole trade-off. A JWT is a cache with a TTL, and it has all the properties of a cache: fast, and capable of being wrong.

Staleness is the real cost

Every problem attributed to JWTs is a staleness problem wearing a different hat.

You demote a user from admin to member. Their current access token still says "role": "admin", and it will keep saying so until it expires. For the next fifteen minutes — or however long your access token lives — they retain admin privileges everywhere in your system. The database is correct. The token is stale, and the token is what your services are reading.

The same applies to every other fact you embed:

  • User removed from an organization → still has the org claim.
  • Account suspended → token still verifies.
  • User signed out → token still works until expiry.
  • Plan downgraded → still has the entitlement claim.
  • Password reset after a compromise → the attacker's existing token is unaffected.

The mitigation everyone reaches for is short expiry, and it does work — it directly bounds the staleness window. But notice what short expiry costs: the client must refresh constantly, and each refresh is a round trip to a stateful service that checks a database. Push it far enough and you have reinvented the opaque token with more steps and worse revocation.

This is the honest framing that cuts through the argument: a JWT with a five-minute expiry and a refresh flow is not stateless. It is a stateful system with a five-minute cache. Once you see it that way, "should I use JWTs?" becomes "what cache TTL can my authorization model tolerate?", which is a question with an actual answer.

When each one is right

Use opaque tokens when:

  • Revocation must be immediate. Financial services, healthcare, admin consoles, anything where "logged out" has to mean logged out right now.
  • Permissions change during a session. If an admin can change someone's role and expect it to take effect on their next click, snapshots are wrong by construction.
  • All verification happens in your own infrastructure. If every service can reach your session store cheaply, the lookup is a few milliseconds and buys you correctness.
  • You need visibility. "Show me every active session, on every device, and let me kill any of them" is trivial with a table of tokens and awkward without one.
  • The token might end up somewhere it shouldn't. An opaque token leaks nothing on inspection. A JWT is base64, not encryption — anything in it is readable by anyone who obtains it, including the user, including whoever ends up with the browser history.

Use JWTs when:

  • Verification crosses a trust boundary. A third party who must verify your tokens cannot query your session database. This is the strongest case for JWTs and the one they were designed for.
  • Verifiers are numerous, distributed, or latency-sensitive. Edge functions, an API gateway on another continent, a service mesh — a signature check with a cached public key is genuinely faster than a network round trip.
  • The claims are inherently stable. "This person authenticated at this time and is identified by this ID" does not go stale. Identity assertions are a good fit; live authorization state is not.
  • You are handing off between systems. The embedded auth pattern is exactly this: a short-lived signed assertion crosses from one origin to another, is verified once, and is immediately exchanged for a first-party session. The JWT lives for seconds and is never a long-term credential.

The hybrid that most systems actually want

Stated as a choice, it is a false one. The design most mature systems converge on uses both, for different jobs:

Rendering diagram…

The refresh token is opaque, stored server-side, revocable instantly, and the thing your "active sessions" UI lists. The access token is a short JWT that services verify cheaply without a round trip. Revocation kills the refresh token, and the blast radius is bounded by the access token's remaining life.

This is what session management best practices recommends, and it is the right default for most products. It buys stateless verification on the hot path and real revocation on the control path, at the cost of a refresh flow — which you needed anyway.

The number to tune is the access token lifetime, and now it has a clear meaning: it is your maximum revocation latency. Fifteen minutes is a decision, not a default. Say it out loud in the design review — "a fired employee retains access for up to fifteen minutes" — and see whether anyone objects. Sometimes they do, and that is exactly the conversation worth having.

Token introspection: the third option

RFC 7662 defines an introspection endpoint — the token holder asks the authorization server whether a token is currently active and what it grants:

POST /introspect
token=eyJhbGciOi...

{ "active": true, "sub": "user_123", "scope": "contacts:read", "exp": 1720000900 }

This gives you real-time state for tokens that are otherwise self-contained, which sounds like the best of both worlds. It mostly is not, for two reasons.

First, if every request introspects, you have paid the network round trip and gained nothing over an opaque token — with more moving parts. Second, the natural fix is to cache introspection results, and now you have a cache with a TTL, which is where you started.

Introspection earns its place in one specific situation: selectively, on high-value operations. Verify the signature for the ninety-nine percent of requests that read data; introspect for the one percent that move money, change permissions, or delete things. That is a defensible design, because it puts the expensive check exactly where staleness would actually hurt.

Closing the revocation gap without giving up JWTs

If you need JWTs for architectural reasons but cannot accept the staleness window, there are three patterns, in increasing order of cost.

A denylist of revoked token IDs. Every token carries a jti; revocation adds it to a shared set that verifiers check. This is often dismissed as "that's just state again," which is technically true and practically unfair — the set is tiny, since entries can be dropped once the token would have expired anyway, and it fits in memory at the edge. A Bloom filter with a definitive check on a positive hit keeps it fast at scale.

A per-user invalidation timestamp. Store one value per user: the time of their last "invalidate everything" event. Reject any token issued before it. One small lookup, cacheable, and it cleanly handles password resets, sign-out-everywhere, and role changes in a single mechanism. This gives you most of the benefit of a denylist with far less state.

Sender-constrained tokens. DPoP or mTLS binding ties the token to a key the client holds, so a stolen token cannot be replayed elsewhere. This does not solve revocation, but it substantially reduces why you needed fast revocation — theft stops being the primary threat.

The mistakes each choice hands you

JWT-specific:

  • Putting data in the token that changes faster than the token expires.
  • Treating base64 as confidentiality. Every claim is world-readable by anyone holding the token. PII in a JWT is PII in your logs, your browser history, and your error reports.
  • Long-lived access tokens. A 30-day JWT is an unrevocable 30-day credential, and it is the single worst configuration in this entire space.
  • Token bloat. Claims accumulate, and one day a request fails because the header exceeded the proxy's 8KB limit. See designing JWT claims.
  • Trusting the alg header, or skipping aud and iss validation.

Opaque-specific:

  • The session store becoming a single point of failure with no fallback. Plan for degraded mode.
  • Unbounded growth. Sessions need expiry and cleanup, or the table becomes the largest one you own.
  • A cache in front of it that reintroduces staleness — silently, without the explicit TTL decision a JWT at least forces you to make.
  • Cross-region latency turning a "few millisecond" lookup into a hundred-millisecond one on every request.

A decision procedure

  1. How long may a revoked credential keep working? If the answer is "zero," opaque or introspection on the hot path. If it is minutes, JWTs with short expiry work.
  2. Does anyone outside your infrastructure verify these tokens? If yes, you need JWTs for that boundary — but only for that boundary.
  3. Is what you are encoding identity or authorization? Identity is stable and safe to embed. Live authorization state is not.
  4. Can you tolerate a stateful lookup on the hot path? Measure it before assuming you cannot. A local cache in front of a session store is frequently faster than people expect.
  5. Default to the hybrid — opaque refresh, short JWT access — unless one of the above pushes you elsewhere.

Key takeaways

  • The axis is staleness, not statelessness. A JWT is a cache of authorization state with a TTL equal to its expiry.
  • Access token lifetime = maximum revocation latency. Set it deliberately and say the consequence out loud.
  • Opaque for immediate revocation, session visibility, and changing permissions. JWT for crossing trust boundaries and distributed verification.
  • The hybrid — opaque revocable refresh token, short-lived JWT access token — is the right default for most products.
  • Introspection is worth it selectively, on high-value operations, not on every request.
  • A per-user invalidation timestamp closes most of the revocation gap for a very small amount of state.

Written by

Emilian Gheonea

Senior Blockchain & Full-Stack Software Engineer. I build EmbedAuth — an embeddable authentication platform for SaaS — and write about the auth problems most teams hit too late.