Token Introspection vs Local JWT Validation

Local JWT validation is fast, self-contained and slightly wrong: it accepts a token that was revoked five minutes ago. Introspection is correct and puts a network call on every request. Most production systems need both, applied to different routes, and the interesting work is deciding where the boundary sits and how to keep the correct-but-slow path from becoming a dependency that takes the API down with it.

Prerequisite concepts

This assumes the token lifecycle and key-rotation model in authentication proxying and token validation, and the zero-trust framing in security boundaries and zero trust.

What each one actually checks

The same token, two different questions answered Local validation answers whether the token was issued by a trusted authority and has not expired, using only cached public keys. Introspection additionally answers whether the token is still active right now, which no amount of local cryptography can determine. local validation token arrives verify signature, exp, aud cached keys, no network answers: was this issued to this audience, by someone I trust, and is it in date? introspection token arrives ask the issuer a network call, per request answers all of the above, plus: is it still active right now? No local check can answer the last question — revocation is state held by the issuer, and a signature cannot encode a fact that came into existence after it was written.

Splitting by route, not by system

The useful boundary is per route, chosen by what an accepted-but-revoked token would let someone do.

Token lifetime is a revocation control you already own With local validation, a revoked token stays usable until it expires: an hour for a long-lived token, fifteen minutes for a moderate one, five minutes for a short one. Introspection with a thirty-second cache reduces the window to seconds, at the cost of a network call on the request path. how long a revoked token keeps working local, 1 h token up to 60 minutes local, 15 min token up to 15 minutes local, 5 min token up to 5 minutes — no network call needed introspection, 30 s cache up to 30 seconds — one round trip per cache window Shortening the token is often the better trade: most of the benefit, none of the added dependency.
# Kong 3.x — local validation everywhere, introspection on the routes that matter
services:
  - name: catalogue
    url: http://catalogue.internal:8080
    routes: [{ name: catalogue-read, paths: ["/v2/catalogue"], methods: ["GET"] }]
    plugins:
      - name: jwt                       # local: signature + exp, no network
  - name: payments
    url: http://payments.internal:8080
    routes: [{ name: payments-write, paths: ["/v2/payments"], methods: ["POST"] }]
    plugins:
      - name: oauth2-introspection
        config:
          introspection_url: https://idp.example.com/oauth2/introspect
          ttl: 30                        # cache the positive answer briefly
          timeout: 800                   # ms — bound the dependency
          run_on_preflight: false

Reading a product catalogue with a token revoked two minutes ago is a non-event. Moving money with one is not. That asymmetry is the whole design: pay the round trip where the consequences justify it.

Caching, and the two things it must not do

A cache in front of introspection is mandatory at any real request rate, and it has two failure modes worth designing against explicitly.

Cache the yes briefly, cache the no longer, and decide the third case in advance A positive answer is cached for a short window, which bounds how long a revoked token keeps working. A negative answer can be cached longer because a token that was inactive rarely becomes active again. When the issuer is unreachable there is no correct default: failing open keeps traffic flowing and honours revocations late, failing closed protects the resource and turns an identity provider outage into an API outage. active: true cache 30 s revocation takes effect within one cache window active: false cache 5 min a dead token stays dead — and a retry storm cannot amplify issuer unreachable no correct default open: traffic flows, revocations late closed: their outage is your outage Cache the token by a hash of its value, never by the raw token — cache dumps and heap snapshots are exactly where credentials should not be sitting in plaintext. Whichever failure mode you choose, emit a metric for it: an introspection layer that silently stopped introspecting looks exactly like one that is working perfectly.

Decision matrix

Route characteristic Validation
Read-only, non-sensitive local
Writes money or grants access introspection, short cache
Called at very high rate local, with short token lifetimes instead
Administrative or privileged introspection, no cache
Third-party opaque tokens introspection — there is no alternative
Internal service-to-service workload identity, neither

Short token lifetimes are the underrated third option: a five-minute token bounds the revocation window without any per-request network call, and for many APIs it is a better trade than either extreme.

Gotchas and failure signals

Introspection is an upstream dependency with a timeout, a retry policy and a failure mode. Treat it as one: bound it, monitor it, and know what happens when it is slow rather than down — a two-second introspection call on a route with a one-second budget fails every request while the identity provider reports itself healthy.

Caching a negative answer protects you from a client retrying an invalid token in a loop and turning your gateway into a denial-of-service amplifier against your own identity provider.

Introspection responses can carry claims local validation cannot see, such as current scope after a downgrade. If you route or authorise on those, they are only correct on the introspected path.

A gateway that introspects on every request and caches nothing will be the reason the identity provider falls over, and the incident will look like an identity provider problem.

Refresh tokens change the calculation

Short access tokens are only tolerable if renewing them is cheap and invisible, which is what a refresh token provides: the client holds a long-lived credential it presents only to the identity provider, and exchanges it for a short-lived access token the gateway can validate locally.

That arrangement moves the revocation problem to a place better suited to solve it. Revoking the refresh token stops renewal, so the blast radius is bounded by the access-token lifetime rather than by the session length, and the check happens at the identity provider rather than on every API request. From the gateway’s point of view nothing changes at all — it keeps validating short-lived tokens locally — while the security property improves substantially.

The failure mode to watch for is a client that treats renewal as exceptional rather than routine. A client that only refreshes after receiving a 401 will produce a burst of renewals whenever a large cohort of tokens expires together, and that burst lands on the identity provider rather than on the API. Stagger token lifetimes slightly, or have clients renew proactively at a random point inside the last third of the lifetime.

Validation

  • Route-level split documented, with the reason each route sits where it does
  • Introspection has an explicit timeout well inside the route’s latency budget
  • Positive and negative cache lifetimes set separately and deliberately
  • Failure mode chosen, exercised in a game day, and exported as a metric
  • Cache keys are hashes, never raw tokens
  • Token lifetime considered as an alternative before adding a network call

FAQ

Why can local validation not detect a revoked token?

Because revocation is state held by the issuer that came into existence after the token was signed, and a signature cannot encode a fact from the future. Local validation can only tell you the token was issued by someone you trust, to the audience you expect, and has not yet expired. Everything else requires asking the issuer.

How do I decide which routes get introspection?

By what an accepted-but-revoked token would let someone do. Reading a public catalogue with a token revoked two minutes ago is a non-event; moving money with one is not. Put introspection on the routes where the consequences justify a round trip, and use local validation with short token lifetimes everywhere else.

How long should I cache an introspection result?

Cache a positive answer for tens of seconds, which bounds how long a revoked token keeps working, and a negative answer for minutes, since a token that was inactive rarely becomes active again. Caching the negative result also stops a client retrying an invalid token in a loop from turning your gateway into an amplifier against your own identity provider.

What should happen when the identity provider is unreachable?

There is no universally correct answer, which is exactly why it must be chosen deliberately rather than inherited from a default. Failing open keeps traffic flowing and honours revocations late; failing closed protects the resource and converts an identity provider outage into an API outage. Whichever you pick, export a metric, because an introspection layer that silently stopped introspecting looks identical to one that is working.


Parent: Security Boundaries & Zero Trust