How to revoke a JWT, from a system that does it
How do you revoke a JWT?
Store a cut-off timestamp on the user, and refuse any token whose session began before it. Check it on every authenticated request, not at login. Compare the cut-off against the claim recording when the session started — not when the token was issued — because refreshing a token resets the second and must not reset the first.
Everything below is how that works in a live system, written 15 September 2026.
The specification gives you nothing to work with
The specification that defines JWTs does not define a way to revoke one.
That is not an oversight you can look up a workaround for.
RFC 7519 gives you
exp — "the expiration time on or after which the JWT MUST NOT be accepted for
processing" — and iat, and nothing else bearing on whether a token is still
good. There is no revocation list in the document, no invalidation, no early
termination.
RFC 7009, the standard for token revocation, is the one that says so out loud. Section 3 notes that self-contained tokens let a resource server decide without asking anyone, and then concedes that revoking them immediately needs
some (currently non-standardized) backend interaction between the authorization server and the resource server
Currently non-standardized. Everyone builds their own. This is ours, running in a multi-tenant billing and inventory product, including the part that was wrong for a while.
The mechanism is one column and two lines
JWT revocation here is a nullable timestamp on every user row. A session that began before that instant is refused:
function beganBefore(payload: JwtPayload, validFrom: Date | null) {
return validFrom !== null && payload.auth_time * 1000 < validFrom.getTime()
}
Three things set it: a self-service password reset, an owner resetting a staff member's password, and the admin panel's "sign everyone out". Null means every session stands, which is the normal state for almost every row.
That is the whole kill switch. The interesting part is not the column. It is which claim the comparison uses.
auth_time, not iat — and this is where implementations quietly break
Tokens in this system slide. A request made more than an hour after the token was issued gets a fresh one on the way out, so a session in daily use never ends mid-shift.
That refresh writes a new iat. It has to — the token really was issued then.
So if the cut-off is compared against iat, here is what happens. An
administrator ends a user's sessions at 4pm. The user's browser makes a request
at 4:01 carrying a token issued at 3:30, which is before the cut-off, and is
correctly refused. But if that user's token had been refreshed at 4:05 by any
request that slipped through — a race, a second tab, a retry — the new token
carries iat = 4:05, which is after the cut-off, and it passes. The
revocation undoes itself.
auth_time is the OpenID Connect
claim for when the authentication actually happened, and a refresh carries it
forward unchanged. It is the session's birthday, not the token's. Compare against that and a refreshed copy of a
revoked session is still revoked, because the thing being tested is the session,
which is what you meant to revoke in the first place.
This is worth stating plainly because the pages currently ranking for this
question get it wrong in a specific, consistent way. Of the ones I could read:
one compares its per-user cut-off against iat, another against exp. Both are
correct for a system with no token refresh — and all of them recommend
refresh tokens, in the same article, as the remedy for the revocation problem.
The recommended remedy breaks the recommended check. I have not seen anyone
draw that circle.
Our end-to-end test has an assertion whose label is exactly this:
a refreshed copy of it is ended too — the cut-off is on
auth_time
The bug: whole seconds against a millisecond column
One failure in this design only appears in production, and it comes straight out of the specification rather than out of carelessness.
OpenID Connect Core 1.0
defines auth_time as
a JSON number representing the number of seconds from 1970-01-01T00:00:00Z
Seconds. The database column is a timestamp, with milliseconds. And the two values are written inside the same second by design: changing your own password sets the cut-off and then immediately issues you a new session, because you should not be signed out of the browser you are sitting in front of.
So: cut-off at 16:04:03.412. New session issued at 16:04:03.907, floored to
a whole second, auth_time = 16:04:03.000. Which is before the cut-off. The
brand-new session is refused on its very first request.
The symptom is "set a new password, then sign in with it — and get bounced to the login page." Intermittent, because whether it happens depends on where in the second the two values land. Roughly half the time it works fine, which is the worst kind of bug to be handed as a report.
The fix rounds up past the cut-off instead of down to now:
const authTime = after ? Math.max(now, Math.ceil(after.getTime() / 1000)) : now
Math.ceil puts the new session at or after the cut-off, so it survives its own
comparison, while a session that genuinely did begin earlier still loses. The
test for it waits out a second boundary before asserting, for exactly the same
reason the bug existed.
This is structural, not carelessness. Anyone who stores a millisecond
timestamp and compares it against a spec-compliant auth_time meets it. It is
simply not in anybody's tutorial.
A cut-off is worth nothing on a route that does not check it
A revocation cut-off only applies on routes that read it, and for a while ours applied on fewer than we believed.
Every session read now goes back to the database — the membership row for a shop
session, the user row for a staff one — rather than trusting the token alone.
That read is where sessions_valid_from gets checked. Originally only the
shop-addressed routes did it. Eighteen others read the tenant id straight off
the token: bills, customers, the team sheet, print settings.
Those eighteen kept serving a revoked login until its token ran out on its own. With sessions that slide for up to thirty days, "we removed their access" could have meant a month of continued access to the customer list.
The fix is one indexed read per request. That is the entire cost, and it is what makes "access removed at 4pm stops at 4pm" true everywhere rather than in most places. A revocation mechanism that some routes consult is not a revocation mechanism; it is a revocation mechanism and a list of exceptions nobody wrote down.
The same read is what lets a role change take effect immediately, incidentally: the role comes from the membership row, not from the token, so an operator demoted mid-session loses the admin panel on their next request rather than at their next login.
The three numbers, and why one of them is sixteen
| | | |
| --- | --- | --- |
| Idle window | 16 hours | A token dies this long after it was issued |
| Absolute cap | 30 days | Measured from auth_time; no refresh passes it |
| Refresh after | 1 hour | Older than this, a request re-issues the token |
exp is the earlier of now + idle and auth_time + cap, so a session in
constant use still ends after thirty days and asks for the password again.
Sixteen hours is not a round number chosen because it looked tidy. This product is used by retail shops. Sixteen hours covers a shop's night — closed at nine, open at ten — while a phone left in a taxi on Saturday evening is signed out by Sunday noon. Pick the number from how the thing is actually used, and it stops being arbitrary.
What a per-user cut-off does not give you
Revocation here is per user, not per token. There is no way to end one device's session and leave the others. Ending sessions ends all of them. For a shop with a counter tablet and an owner's phone that is the honest behaviour anyway; for a consumer product with a device list it would not be enough, and you would need per-session rows, which is a database write per login we do not currently make.
It is not a blacklist, and I am not arguing blacklists are wrong. A deny list of token identifiers is a perfectly good design and it gives you per-token revocation, which this does not. We do not run one, so I cannot tell you anything useful about operating one at scale, and this piece will not pretend otherwise.
Revocation is immediate, but only as immediate as the next request. Nothing reaches out and invalidates anything. A tab sitting idle is not signed out until it asks the server for something.
Rotating the signing secret is a separate problem with a separate answer — and the kind of thing we work through in security and compliance engagements — ours rotates without signing anybody out, via a secret that verifies but no longer signs. That is its own write-up rather than a paragraph here.
What actually makes JWT revocation work
Not the column. A nullable timestamp is the easy part of this.
- The comparison is against the session's start, not the token's, so a refresh cannot launder a revoked session into a valid one.
- The claim is rounded up past the cut-off, because the spec says seconds and the database says milliseconds and something has to give.
- Every authenticated route reads the session from the database, so there is no route where the cut-off silently does not apply.
- There is a test that ends a session, refreshes a copy of the ended token, and asserts that the copy is refused too — forty assertions against a running server, because a revocation feature nobody has tried to defeat is a feature nobody knows the shape of.
Tenancy has the same shape as revocation: the guarantee is whatever every route does, not whatever the best route does. That is most of what building a SaaS product turns out to be.
That last one is the part I would keep if I had to throw the rest away. Every approach here is a design somebody could argue with. The test is the only thing that says it works.
Questions people ask
- How do I revoke an OAuth token?
- If it is an opaque token, the authorization server has a revocation endpoint and RFC 7009 defines it. If it is a self-contained token such as a JWT, RFC 7009 section 3 says the authorization server and the resource server need some currently non-standardized backend interaction to revoke it immediately. There is no standard mechanism, which is why every system invents one.
- Is it possible to decrypt a JWT token?
- A standard JWT has nothing to decrypt. JWTs are signed, not encrypted: the header and payload are base64url-encoded, and anyone holding the token can read every claim in it without a key. The signature proves the claims were not altered, not that they are secret. Never put anything in a JWT you would not put in a URL.
- What if someone steals my JWT token?
- Until it expires, it works. That is what self-contained means. Two things limit the damage: a short idle window, so an unused token dies on its own, and a per-user cut-off timestamp the server checks on every request, so an administrator or a password change can end every session at once. We use a sixteen-hour idle window and a sessions_valid_from column.
- How to revoke an access token?
- Store a cut-off timestamp per user, and on every request refuse any token whose session began before it. The critical detail is which claim you compare against: it must be the one recording when the session began, not when the token was issued, because refreshing a token resets the second and must not reset the first.