Blocking a user mid-session: revoking JWTs before they expire
JWTs are great until you need to log someone out right now. A token valid for 8 hours stays valid for 8 hours. Here is how token versions in Redis make revocation instant on every HTTP request, why WebSockets needed an extra check, and the small design choices that keep the whole thing safe.
The problem
A logged-out or blocked user kept a valid 8-hour JWT, and an already-open WebSocket could keep acquiring edit locks with it.
The result
Revocation is instant: every request and every lock acquisition checks a token version in Redis, and stale permissions are detected without waiting for the token to expire.
Stack
The problem
Authentication for the configurator engine is handled by a central auth service. It issues a JWT access token (valid for 8 hours) and the application checks it on every request. JWTs are popular for a good reason: the application can verify a token by itself, with just the signing secret, without calling the auth service each time.
But that strength is also their weakness:
A signed JWT stays valid until it expires. You can't "un-sign" it.
So what happens when:
- a user clicks Log out, but someone copied their token?
- an admin removes a person from the application?
- a user's permissions change, and they should lose access to a page now, not tonight?
- a device is lost, and we need a forced logout everywhere?
With plain JWT verification, the answer to all of these is: the token keeps working for up to 8 hours.
And there was a second, sneakier problem. Edit locks are acquired over a WebSocket. A socket is authenticated once, when it connects. A socket opened in the morning could stay connected all day, and keep acquiring locks long after the user was logged out.
The details
Option 1: short tokens
Make access tokens expire in 5 minutes and refresh them often. This reduces the window but doesn't close it, adds refresh traffic, and still leaves 5 minutes of access after a forced logout.
Option 2: a denylist
Store every revoked token id until its expiry. It works, but the list grows with every logout, and you must store tokens you've never seen until they're revoked.
Option 3: token versions (what we used)
Instead of tracking tokens, track a version number per user in Redis:
token-version:{userId} → 7
permissions-version:{userId}:{appId} → 3
Every JWT is minted with the versions that were current at that time:
{
"userId": 42,
"name": "Sara",
"tokenVersion": 7,
"permissionsVersion": 3,
"isRootAdmin": false
}
To revoke, the auth service simply increments the user's version: INCR token-version:42 → 8. Every token carrying 7 is now dead, on every device, instantly. One number per user, no growing list.
The auth service owns the versions; this application only reads them, from the auth service's Redis.
The challenges
Verification is no longer "free"
The whole point of JWTs was avoiding a lookup per request. Checking versions adds one Redis read per request. That's a deliberate trade: Redis reads take well under a millisecond, and instant revocation is worth it.
What if Redis is down?
If the version can't be read, the server must choose: let the request through (fail open) or refuse it (fail closed). For authentication, the answer has to be explicit.
Permissions change more often than sessions
A user's role or group can change while they stay logged in. Forcing a full logout for every permission tweak would be annoying; ignoring it would be insecure.
WebSockets don't re-authenticate
The HTTP guard runs on every request. A WebSocket is checked at connect time only. Polling every open socket on a timer would be wasteful, but doing nothing leaves a long-lived gap.
Which id is "the user"?
Users exist in an ERP and in the auth service, each with its own id. If one part of the system checks the ERP id and another checks the auth id, a version check can silently look at the wrong counter.
The solution
1. The HTTP guard: verify → versions → permission
Every protected request goes through the same three steps:
async canActivate(context: ExecutionContext) {
const payload = await this.verifyJwt(request); // signature + expiry
let tokenVersion: number, permissionsVersion: number;
try {
tokenVersion = await this.versions.token(payload.userId);
permissionsVersion = await this.versions.permissions(payload.userId, payload.appId);
} catch {
// Can't verify the session → refuse, but say it's temporary
throw new ServiceUnavailableException('Could not verify your session right now. Please try again shortly.');
}
if (tokenVersion !== payload.tokenVersion) {
throw new UnauthorizedException('This session has been revoked. Please log in again.');
}
if (permissionsVersion !== payload.permissionsVersion) {
throw new StalePermissionsException(); // client refreshes its token
}
await this.checkRoutePermission(context, payload); // see below
return true;
}
Three outcomes, three clear messages:
| Situation | Response | What the client does |
|---|---|---|
| Session revoked | 401 "This session has been revoked" | send the user to login |
| Permissions changed | stale-permissions error | silently refresh the token, retry |
| Redis unreachable | 503 "try again shortly" | retry later |
Fail closed, but honestly. When Redis is down, the request is refused (security first), with a 503 that says temporary, not a misleading 401 that would log everyone out.
2. Two versions, two purposes
- Token version = "is this session still alive?" Bumped on logout, forced logout, user removal.
- Permissions version = "are this token's permissions still current?" Bumped when roles or groups change.
A permissions change doesn't kill the session. The client gets a specific error, refreshes its token in the background, and continues with the new permissions. Users don't notice anything, except that the page they lost access to is now refused.
3. Route permissions come from the same source
Each route declares an action with a decorator (read, create, update…). The full permission name is built by the same helper that publishes the application's endpoint catalog to the auth service. So the permission checked at request time is, by construction, the permission that exists upstream. Root admins skip this check; service tokens never do.
There was a subtle trap here: the application registers under one app name and publishes permissions under a software name. If those ever drift, every permission check looks for a key no group can hold, and users get a 403 with no explanation. So the guard compares them at startup and logs a loud error if they differ. It's cheap to notice at boot and expensive to find later.
4. The WebSocket: re-check at the moment it matters
At connect time, the gateway verifies the JWT (from an httpOnly cookie in browsers, or the Socket.IO auth object for other clients) and stores a snapshot on the socket, including its tokenVersion.
Then, instead of polling every socket on a timer, the version is re-checked right before granting a lock, the one action where it actually matters:
private async stillAuthorized(client: Socket): Promise<boolean> {
const { userId, tokenVersion } = client.data.user;
let current: number;
try {
current = await this.versions.token(userId);
} catch {
client.emit('lock-error', { message: 'Internal error' });
return false; // fail closed
}
if (current !== tokenVersion) {
client.emit('lock-error', { message: 'Unauthorized' });
client.disconnect(); // this socket's session is over
return false;
}
return true;
}
Every lock event starts with:
if (!this.authenticated(client)) return;
if (!(await this.stillAuthorized(client))) return;
A socket opened before a logout can no longer acquire anything. On its next lock attempt it is refused and disconnected, without any background polling.
5. One id to rule them all
All checks use the auth service's own user id, never the ERP id, both in HTTP and in WebSocket code. Tokens minted before that claim existed have no userId and are rejected once, forcing a clean login. A one-time inconvenience that removed a whole class of "checked the wrong counter" bugs.
6. Machine-to-machine tokens
Some calls come from other services, with no user behind them. They use service tokens, recognized by a prefix before parsing, so the user flow is never touched by them. Service tokens follow the same idea (verify → version in Redis → permission), but with no admin bypass: their permission list is always explicit. Every permission change reissues the service token and bumps its version, so one version check covers both cases.
Lessons learned
- JWTs need a revocation story. "It expires eventually" is not one. Decide on day one how you'll log someone out now.
- Version numbers beat denylists. One counter per user, bumped on revocation, kills every old token instantly, with no growing list.
- Separate "session alive" from "permissions current". One should log you out; the other should just refresh your token.
- Fail closed, but say why. A
503"try again shortly" is honest; a fake401would log everyone out during a Redis blip. - Long-lived connections need re-checks. Check again at the moment a sensitive action happens, instead of polling every socket.
- Validate config invariants at startup. A name mismatch that breaks every permission check should crash loudly at boot, not hide behind mysterious
403s. - Pick one user id and use it everywhere. Mixed ids across systems make security checks silently compare the wrong things.
FAQ
Can you revoke a JWT before it expires?
Not the token itself, because its signature stays valid. But you can make it useless: store a version (or a denylist) server-side and check it on each request. Token versions per user are a simple and scalable way to do it.
What is a token version?
A number stored per user (for example in Redis) and copied into each JWT when it's issued. If the stored number is incremented, every token carrying the old number is rejected.
Does checking Redis on every request remove the benefits of JWT?
Partly, but the cost is small: a Redis read is typically well under a millisecond. You keep stateless signature checks and self-contained claims, and gain instant revocation.
How do you authenticate a WebSocket connection securely?
Verify the token during the handshake (from an httpOnly cookie in browsers or an auth payload for other clients), store the user on the socket, and re-check revocation before sensitive actions, since the connection can outlive the session.
Facing a similar problem?
I help teams fix performance, backend and reliability issues in production.
Let's talk