The Session Lifecycle and the JWT Revocation Gap (Diagram)

A stateless JWT is valid because it says it is. Your server reads the signature and the expiry inside the token, decides it looks fine, and serves the request. No lookup, no database, no list of who is still allowed in.

That's the speed. It's also the problem, and this diagram is the shape of it.

A state diagram of a login credential. From Anonymous, login moves it to a live Signed in state where a stolen copy also works. Two transitions leave that state: Revoked, reachable only for server-side sessions when you log out or ban an account, and Expired, which happens when the expiry time passes and is the only exit available to a stateless JWT.
Two ways out of the live state. A stateless JWT only has the right-hand one.Mermaid source

Reading the diagram

Read it top to bottom. Each box is a state the credential is in, and each arrow is a thing that moves it.

The grey box is where everyone starts. No credential exists yet, so there is nothing to steal.

The red box is the entire security question. Login hands out a credential with an expiry already written into it, and from that moment the credential is live. Note the third line: a stolen copy works here too. A credential does not know who is holding it. If someone copied the token out of a log, out of localStorage, off a shared laptop, their copy sits in exactly this same state as the legitimate one, with the same powers.

Two arrows leave the red box, and they are not equivalent.

The left arrow, to Revoked, is you making a decision. A user logs out, or you ban an account, or a password gets reset. The server writes down that this credential is finished, and the next request carrying it gets refused. That arrow requires the server to keep a record and check it, which is what "server-side session" means.

The right arrow, to Expired, is not a decision. It's a clock running out. Nobody chose it, nobody can bring it forward, and it happens at exactly the time that was baked in at login.

The bold label is the whole point. For a stateless JWT, the left arrow does not exist. There is no record to write and nothing that checks one. The only way out of the live state is waiting.

What that means in practice

A founder shipping a JWT-based login almost always believes logout works. It looks like it works: click the button, get bounced to the sign-in page, and every subsequent request fails.

What actually happened is that the browser deleted its own copy. That's a client-side change to a client-side thing. Nothing was communicated to the server, because with a stateless token there is nothing on the server to communicate with.

So the honest description of "log out" under a stateless JWT is: the credential is still valid, and you have politely stopped using it.

This is the gap that turns a small incident into a long one. If a token leaks on a Monday and your expiry is 30 days, an attacker has until roughly the following month, and there is no button anywhere in your app that shortens that. Rotating a secret, resetting a password, and deleting the user account all leave the already-issued token working.

The same gap explains a bug report that confuses people: an admin removes someone's access, that person keeps working normally for hours, and everybody assumes the permission system is broken. It isn't. The permission change landed in the database. The token in that person's browser still asserts the old role, and nothing is reading the database to notice.

Closing the gap

There is no way to keep stateless verification and get revocation. You are choosing which one you want, and you can choose differently per route.

The usual resolution is to make the live state short instead of trying to exit it early. Issue an access token measured in minutes, and pair it with a refresh token that is stored server-side and can be revoked. The access token still cannot be recalled, but the window where that matters shrinks from a month to the length of a coffee break, and the thing an attacker actually wants long-term access to, the refresh token, sits behind a check you control.

The other options are a denylist of revoked token IDs consulted on each request, or a version number per user that invalidates everything issued before a bump. Both work. Both mean a lookup, which means the token is no longer stateless, which is the tradeoff being made either way.

Pick deliberately. The failure mode is not choosing JWTs. It's choosing them for the speed and then assuming you also got the revocation.

The source

This diagram is generated from the file below, and the render is checked against it in CI. Copy it into any Mermaid renderer to remix it.

stateDiagram-v2
    direction TB

    state "Anonymous.<br/>No credential yet." as Anon
    state "Signed in.<br/>The credential is live.<br/>A stolen copy works here too." as Live
    state "Revoked.<br/>The server refuses it." as Revoked
    state "Expired.<br/>The clock refuses it." as Expired

    [*] --> Anon
    Anon --> Live: login issues a credential<br/>with an expiry baked in

    Live --> Revoked: you log out, or<br/>you ban the account.<br/>server-side sessions only
    Live --> Expired: the expiry time passes.<br/><b>a stateless JWT<br/>has no other exit</b>

    Revoked --> [*]
    Expired --> [*]

    classDef neutral fill:#f5f5f4,stroke:#57534e,stroke-width:3px,color:#1c1917
    classDef danger fill:#fef2f2,stroke:#ef4444,stroke-width:3px,color:#1c1917
    classDef safe fill:#ecfdf5,stroke:#10b981,stroke-width:3px,color:#1c1917

    class Anon neutral
    class Live danger
    class Revoked safe
    class Expired safe

Why can't I just invalidate a JWT when someone logs out?

Because nothing is checking with you. A stateless JWT is verified by reading the signature and the expiry inside the token itself. Your server never looks anything up, which is the entire performance argument for using one. Logging out deletes the copy in that browser. It does not reach any other copy, and there is no list your server consults that you could remove it from.

What actually happens when a user clicks log out with a JWT?

The client throws its own token away. That is the whole operation. If the token was already copied, by malware, by a shared machine, by a leaked log line, or by anyone who read it out of localStorage, that copy is untouched and keeps working until its expiry passes.

How do I get revocation back?

You add state, which means giving up the property that made the token stateless. The common approaches are a denylist of revoked token IDs checked on each request, short access tokens paired with a refresh token you can revoke server-side, or a token version number stored per user that invalidates every token issued before a bump. All three mean a lookup on requests that need to be revocable.

How short should my token expiry be?

Short enough that the window you cannot close is a window you can live with. With no revocation path, the expiry is your entire incident response, so a 30-day access token means a stolen token is usable for up to 30 days. Minutes for an access token, with a revocable refresh token behind it, is the usual shape.

Does this mean JWTs are a bad choice?

No. It means the tradeoff is real and worth making on purpose. Stateless verification is genuinely faster and scales across services without a shared session store. The mistake is picking a JWT for those benefits and then assuming logout, password reset, or banning an account takes effect immediately. Those need a revocation path that a stateless token does not have.

Is your token sitting somewhere it shouldn't?

A scan reads what your live app actually ships to the browser, including tokens left in client-side storage and endpoints that accept an expired one.

diagrams

The Session Lifecycle and the JWT Revocation Gap (Diagram)