← All posts
Web security

Your JWT cannot be logged out

The forgery bugs get all the attention and they are the easy half. The hard half is that a signed token stays valid until it expires, whatever you do afterwards — so a logout button, a password reset and a termination all quietly fail to end a session.

Someone resigns badly. You disable the account inside a minute, and feel fine about it.

Their access token was issued forty minutes ago with a twenty-four hour expiry. It is signed, it is valid, and your API will keep accepting it until tomorrow afternoon, because accepting it requires no contact with your database at all. That is not a bug in your implementation. It is the feature you chose.

JWTs attract a lot of writing about forgery — alg: none, algorithm confusion, guessable secrets. Those are real and they are the easy half, because they have fixes you can apply once and be done with. The half with no clean fix is the one nobody puts in the tutorial.

First, the part people get wrong about reading them

A signed JWT is three base64url strings joined by dots. The first two are not encrypted. They are encoded, which is a transport convenience, not a security property, and anyone holding the token can read them in a second.

This gets stated often and absorbed rarely, because the token looks like ciphertext. It is not. If your payload contains an email address, a phone number, a full name, an internal user id scheme or a role hierarchy, you have published all of it to anything that handles the token — the browser, every proxy, your logs, the error tracker, the CDN, and anyone who gets a copy.

Put in the payload what a verifier needs and nothing else.

The forgery bugs, briefly

They deserve a short section rather than a long one, because the list is well-covered and finite.

alg: none. The header declares no signature algorithm. A verifier that honours it accepts a token anyone can construct. Reject it outright, and pin the algorithm you expect rather than reading it from the token — the token is the untrusted input.

Algorithm confusion. You expect RS256, signed with a private key and verified with a public one. An attacker re-signs the token as HS256, using your public key as the HMAC secret. A naive verify(token, key) that takes the algorithm from the header will check an HMAC against a key it happily hands over, because the public key is public. Same fix: pin the algorithm.

A guessable HMAC secret. With HS256 the same secret signs and verifies, so a secret that is a word can be recovered offline from any token you have ever issued. secret, changeme, the framework default, the string from the example in the docs. It costs an attacker nothing to try, and you can try it yourself in the JWT inspector without the token leaving your browser.

kid, jku and x5u. These tell the verifier which key to use, or where to fetch it. Trusted blindly, kid becomes path traversal or SQL injection, and jku lets an attacker nominate the URL their own key is served from. Treat all three as attacker-controlled, because they are.

Fix those once, pin the algorithm, use a long random secret or asymmetric keys, and forgery stops being your problem.

Now the part that does not have a fix

The selling point of a JWT is that verification is local. The server checks a signature and an expiry and serves the request, with no session lookup. That is genuinely valuable: it is what lets you scale horizontally without shared session state, and what makes a token work across services that share nothing but a public key.

It is also, exactly and unavoidably, the reason you cannot revoke it. A statement you can verify without consulting anyone is a statement nobody can retract. Those are the same sentence.

So consider what the following actually do:

The logout button. Deletes the token from the browser. Does nothing to any copy that already left it. If the token was stolen, logout is a no-op against the person you are worried about.

A password change. Protects future logins. The attacker's token was signed before the change and verifies the same afterwards, because verification never reads the user record.

Disabling the account. Takes effect at the next login, which the holder of a valid token has no reason to perform.

Changing a user's role. Takes effect when a new token is minted. Until then the old one asserts the old role, and your API believes it, because believing it is the design.

Every one of these is a control people assume is immediate. None of them is.

The four ways out, and what each one costs

Short expiry plus refresh tokens. Access tokens live five or fifteen minutes; a long-lived refresh token mints new ones. Revocation becomes "invalidate the refresh token", and your exposure window shrinks to the access token's lifetime. This is the standard answer and it is a good one. Be honest about what it is: the refresh endpoint is stateful, you have moved the session rather than removed it, and the refresh token is now the thing worth stealing — so it needs rotation and reuse detection to have been worth the trouble.

A denylist. Store revoked token ids and check every request. This works and it reinstates the lookup you adopted JWTs to avoid. If the denylist only holds tokens until their natural expiry it stays small, which makes this far more practical than people assume.

A token version in the user record. Put a counter in the claims, bump it on logout or password change, compare on each request. Clean, and it is a database read per request — at which point you have rebuilt server-side sessions with extra steps and worse ergonomics.

Accept the window. Legitimate, if you say it out loud and choose the number. A fifteen-minute window on an internal tool is a reasonable risk. Twenty-four hours on anything holding money is not a decision, it is usually a default nobody revisited.

Notice that three of the four reintroduce state. That is the actual lesson: stateless authentication does not remove the need for revocation, it relocates it. The question is never whether you will hold state, only where.

Where to keep the token

Briefly, because it is where the practical damage happens.

localStorage is readable by any JavaScript on the page, so one XSS is one stolen session — and per everything above, a stolen session you cannot end. An httpOnly, Secure, SameSite cookie is not readable by script, which trades the XSS exposure for CSRF exposure, and CSRF has a well-understood fix that XSS does not.

The common argument for localStorage is that the API is on another domain. That is a real constraint and it is worth solving properly — with a same-site proxy, or CORS plus credentials — rather than accepting token theft as the price of convenience.

When a JWT is the right answer

None of this is an argument against them. It is an argument against the default twenty-four hour, locally-verified, browser-stored access token that most tutorials produce.

JWTs are excellent when the token is short-lived, when the verifier genuinely cannot reach a shared store, and when the assertion is between services rather than standing in for a user's session. Service-to-service calls, federated identity, signed download links, anything where the lifetime is measured in minutes.

They are a poor fit when you need a logout that logs someone out. If your threat model includes a stolen token, a terminated employee, or a support agent who needs to kick someone off right now, you need state somewhere — and you should pick where it goes deliberately, rather than discovering the gap during the incident.

If you want to see what one of yours is carrying, the JWT inspector decodes it, turns the timestamp claims into real dates, flags the forgery surface and tests the secret against common words — entirely in your browser, which matters more here than for most tools, because a real token is usually a live session. Paste one into a server-side decoder and you have handed that session to whoever runs the server.

Web securityAuthentication