DeepConcepts

Security / security / authentication / tokens

You Cannot Recall a Token You Already Issued

The misconception

That calling the revocation endpoint, or clearing the session, logs a user out. It does neither for a self-contained access token. The resource server checks a signature and an expiry claim and asks nobody's permission, so a token issued before the revocation keeps working until it expires. Refresh token rotation does not close that window either — it detects, after the fact, that two parties hold the same refresh token, and the detection cannot happen until the legitimate client next refreshes.

15 min

A self-contained access token — a JSON Web Token (JWT), signed by the identity provider and read directly by your API — is checked without asking anyone. The API verifies a signature and reads an expiry claim. There is no call to the authorization server on that path, which is exactly why the design is popular, and it means nothing you do at the authorization server can reach a token already in someone's hands. Revocation acts on the next token, never the current one.

So the question is never "is this revocable". It is: how many seconds pass between the revocation event and the last request that still succeeds? Call that the residual window. It is a number, you choose it, and most teams choose it by accident.

The panel below runs one hour of a live session at twenty requests a minute. At 30 minutes and 17 seconds something happens — a logout, an administrator disabling the account, a password change, a second party redeeming a copy of the refresh token. Set the access token lifetime, the refresh strategy, and what your API does on each request. The log prints what the authorization server did, what the API knew, and when access actually stopped.

Start with the defaults and press logout. Then drag the lifetime slider from one end to the other without changing anything else.

The deployment
What happens at 30:17

residual access after the event
requests served after it
what finally stopped it
back-channel calls per hour
One hour of the session
access tokens still working

access token in force · requests still succeeding after the revocation event · the vertical rule is 30:17

What actually happened

Two things fall out of that slider that no diagram tells you. First, the residual window under local validation is not "about a minute" or "quite short" — it is a number between zero and the full access token lifetime, averaging half of it, and at 60 minutes it is 30 minutes of a disabled account working normally. Second, switch to refresh token copied with rotation on: the detection does not happen when the copy is used. It happens when the legitimate client next refreshes, which is up to one access token lifetime later. Rotation's detection latency and your residual window are the same parameter wearing two hats.

Why the token cannot be recalled

Not "is hard to recall". Cannot. The property that makes a self-contained token fast is the same property that makes it irrevocable, and they are one property, not two.

A bearer token is either a reference or a value. A reference token is a random string that means nothing on its own; the resource server has to ask the authorization server what it stands for, which is a network call on every request and a place to say "not any more". A self-contained token carries its claims and a signature, and the resource server decides locally. RFC 7009 §3 sets out exactly this fork, and is candid about the consequence: for self-contained tokens, immediate revocation requires "some (currently non-standardized) backend interaction between the authorization server and the resource server", and the alternative it offers is short lifetimes. That is the whole menu. There is no third option where local validation and instant revocation coexist.

This is why a revocation endpoint returning 200 is not reassuring. RFC 7009 §2.2 says the server responds 200 both when the token was revoked and when the client submitted an invalid token, because an invalid token is already unusable and the client could not act on the difference anyway. The status code tells you the request was handled. It does not tell you anything reached your APIs.

Notice what is not the problem: the token's contents being inspectable, or the signature being weak. All of that can be perfect. See what signature verification does and does not establish — this lesson is about the checks that are not on the token at all, because they are checks on the world.

What short lifetimes actually buy

They buy a bounded residual window, and nothing else. That is worth understanding precisely, because the two obvious readings are both wrong.

The window is not the average, it is the worst case. With a 15-minute access token, a revocation lands uniformly somewhere in the token's life, so the expected residual is about 7 minutes 30 and the maximum is 15 minutes. When someone asks "how long after we disable an account does access stop", the answer they need is 15 minutes, not 7.

Shortening the lifetime does not shorten the session. The refresh token does. A 5-minute access token with a 90-day refresh token is a 90-day session checked every 5 minutes, and each of those checks is where revocation, account status and policy get a chance to apply. This is the part most teams have backwards: the access token lifetime is the polling interval of your authorization decisions, and the refresh token lifetime is the session length.

The cost is not free, and the panel counts it. Halving the access token lifetime doubles the refresh traffic. At 60 seconds that is 60 token endpoint calls per hour per active user. That is a real load on the identity provider and a real dependency: when it is slow, every session in the estate stalls together. RFC 9700 §2.3 pushes in a compatible direction — keep the access token's privileges and audience narrow, so that the residual window is a window onto less.

Rotation detects; it does not prevent

Refresh token rotation means the authorization server issues a new refresh token with every refresh and invalidates the old one, keeping a record of the relationship. RFC 9700 §4.14.2 specifies it as one of two ways to protect refresh tokens for public clients, the other being sender-constrained tokens that are cryptographically bound to a client instance.

The wording of the specification is worth reading closely, because it is more modest than the marketing: "If a refresh token is compromised and subsequently used by both the attacker and the legitimate client, one of them will present an invalidated refresh token, which will inform the authorization server of the breach. The authorization server cannot determine which party submitted the invalid refresh token, but it will revoke the active refresh token."

Three consequences follow, and the panel makes each of them visible under refresh token copied.

  • Detection requires both parties to use the token. If the second party uses the copy and the legitimate client never comes back — the user closed the laptop for the weekend — nothing is detected, because there is no second presentation to collide with.
  • Detection latency is the access token lifetime. The collision happens when the legitimate client next refreshes, which is when its current access token expires. Move the slider under this scenario and the detection time moves with it exactly.
  • Detection signs out the legitimate user too. The server cannot tell the parties apart, so it revokes the family. This is correct behaviour and it is also the source of the support ticket "we keep getting logged out", which in practice is usually two tabs racing a refresh rather than anything hostile. Providers handle that with a short grace period during which the previous refresh token is still accepted — which is a deliberate hole in the detection, sized in seconds, and worth knowing the size of in your own provider.

Rotation is worth doing. It is the difference between a leaked refresh token being usable forever and being usable until the next collision. It is not revocation, it does not shorten the residual window, and it is not a reason to lengthen access token lifetimes. The refresh token itself deserves the care its power implies: it renews access long after the authorization code that produced it is forgotten.

The introspection trade, stated honestly

Token introspection (RFC 7662) is the honest answer to revocation, and it costs you the thing you were buying.

The resource server posts the token to the authorization server's introspection endpoint and gets back a JSON object whose only required member is active. RFC 7662 §2.2 defines a true value as meaning the token was issued by this server, has not been revoked by the resource owner, and is inside its validity window — and §4 requires the authorization server to perform every applicable check before answering, including expiry, not-before, revocation and signature. So a true is a real, current statement, which is exactly what the local check cannot give you.

What it costs:

  • A network round trip in the request path. One per API request, to a service you do not own, with its own latency and its own outages. Set the panel to introspection and read the cost figure: 1,200 calls an hour for one user at twenty requests a minute.
  • Authentication on that call. RFC 7662 §4 requires the introspection endpoint to authenticate the resource servers calling it, precisely because an open one lets anyone test token values. So every resource server now holds a credential of its own, and that credential has a lifecycle.
  • The cache that undoes it. The obvious fix for the round trip is to cache the answer, and the cache lifetime is the residual window again. Set the panel to introspection cached for 60 seconds and watch the hero number move from 3 seconds to 60. This is not a bad trade — a 60 second window is far better than a 15 minute one — but it is the same trade, made in a different place, and it should be a deliberate number rather than a default.

Which points at the honest summary: introspection on every request is a server-side session with extra steps. That is not an argument against it. It is an argument for choosing between them on their merits rather than believing that self-contained tokens gave you something for nothing. Denylists sit in between: cheap to read, but you are building the non-standardised propagation path RFC 7009 §3 warned you about, and its delay is your window.

Measuring your own residual window

You can put a number on this today, and it is a better number than any policy document.

  • Decode a real access token from your own staging environment and subtract iat from exp. That difference, in seconds, is your worst-case revocation latency for everything that validates locally. Do not take it from configuration; providers cap and round these values, and the token is the truth.
  • Then time it. Disable a test account, and keep calling a protected endpoint every second with a token minted just before. Record the first 401. That interval is the number to put in your incident runbook, because it is what "we have locked the account" actually means.
  • Check whether your logout calls anything. Open the network tab and log out. If the only request is to your own backend and nothing goes to the provider's revocation_endpoint, then logout clears a cookie and the refresh token in that browser's storage is still valid at the provider. Look for revocation_endpoint in /.well-known/openid-configuration; if the provider publishes it and you never call it, that is a five-line fix.
  • Check what a password change does. Most providers make this a configuration switch, and RFC 9700 §4.14.2 leaves it a MAY. Test it: change a test user's password and try the old refresh token. If it works, your users' most instinctive security action does nothing until every access token has expired, and nothing at all if it never has to.
  • Find your rotation grace period. If you use rotation, submit a just-superseded refresh token and see whether it is accepted. Whatever that window is, it is how long a copy remains usable without tripping detection.
  • Log the token identifier, never the token. The jti claim gives you a per-token identifier you can correlate across services, which is what makes an incident answerable at all. The token itself in a log is a credential in a log, readable by everyone with log access, for the retention period.

One escape hatch to know about and be careful with: rotating the signing key invalidates every token signed with it, everywhere, at once. It is the only instant global revocation a stateless design has, it signs out every user in the estate, and it has a timing problem of its own — see how key sets rotate and why validation breaks during it. Keep it as a break-glass procedure, and rehearse it, because the first time you use it should not be the first time you have run it.

Your access tokens live 15 minutes. You add refresh token rotation with reuse detection. An employee is offboarded and disabled in the identity provider at 09:00. When does their access to your APIs stop?

The other side of this trade is the design that pays the lookup on every request by default — server-side sessions and what they cost — and the flow that hands out these tokens in the first place, where the code is bound before any token exists.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.