In this article
August 6, 2026
August 6, 2026

Stateless JWTs have a logout problem — and enterprise customers will find it

Stateless JWTs stay valid until they expire, so logout isn't instant. Here's why enterprise SCIM makes that a compliance problem — and how to really fix it.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Stateless JWTs are the default session strategy for a reason. No session table, no database round-trip on every request, and horizontal scaling that just works because any server can verify a token with a public key. Then a customer's security team asks a simple question during procurement: "When we deactivate an employee, how fast is their access actually revoked?" And the honest answer — "up to fifteen minutes, or however long the token lives" — is the wrong answer.

The problem is baked into what makes a JWT stateless. A signed JWT is valid until it expires, no matter what the server does. There is no "log this user out" button that reaches into a token already sitting in someone's browser. Once you've signed it, it's a bearer credential in the wild until the clock runs out.

Why "just log them out" doesn't work

When you log a user out of a session-backed app, you delete the session row and the next request fails. The server is the source of truth, so revocation is instant.

A stateless JWT flips that. The token is the source of truth. Your API trusts it because the signature checks out and the exp claim is in the future — it never asks a database whether the session is still good. That's the whole point, and it's why verification is fast. It's also why you can't take the token back.

import { jwtVerify } from 'jose';

// Verification is purely local: signature + claims. No I/O, no session lookup.
async function verify(token: string, publicKey: CryptoKey) {
  const { payload } = await jwtVerify(token, publicKey, {
    issuer: 'https://auth.example.com',
    audience: 'api://default',
  });
  // If this resolves, the request is authorized — even if the user
  // was deactivated 30 seconds ago. Nothing here checks for that.
  return payload;
}

Every mitigation for this comes down to reintroducing the state you removed, in one form or another.

IMAGE: A rounded rectangle on the left labeled by position as the token issuer, emitting a directional arrow to a small circular token shape that then fans out to three server nodes on the right; each server has a self-contained checkmark glyph and no line back to a central store; cool blues for servers, a warm amber accent for the token; flat, minimal, generous whitespace

Your two real options

There are two ways to make revocation possible, and they trade off against each other.

Short TTLs with refresh-token rotation. Keep access tokens short-lived — minutes, not hours — so a revoked user's access expires on its own almost immediately. The user's client silently exchanges a long-lived refresh token for a new access token when the old one expires. Revocation then means refusing to issue the next access token: you revoke at the refresh boundary instead of mid-token.

A server-side denylist. Keep the longer-lived tokens but check every incoming token's ID against a list of revoked tokens. This works, and it's precise, but it reintroduces state — now every request does a lookup, which is the database round-trip you adopted JWTs to avoid.

Most teams land on the first option. The window shrinks to your access-token TTL, and you don't pay a lookup on every request. The tradeoff is that "instant" really means "within one token lifetime," so the TTL you choose is a direct security-versus-latency decision.

// Refresh endpoint: this is where revocation actually happens.
async function refresh(refreshToken: string) {
  const record = await store.findRefreshToken(refreshToken);

  // The user was deactivated? Don't mint a new access token.
  if (!record || record.revoked || !record.user.active) {
    throw new Error('refresh_denied');
  }

  // Rotate: the old refresh token is now spent.
  await store.revokeRefreshToken(record.id);
  const next = await store.issueRefreshToken(record.userId);

  return {
    accessToken: signAccessToken(record.userId, { expiresIn: '5m' }),
    refreshToken: next.token,
  };
}
IMAGE: A horizontal timeline of five short segments in sequence, each a small rounded bar, with a curved arrow looping from the end of each segment back to the start of the next to suggest renewal; one segment near the middle is cut short with a small warning-colored break mark where the loop stops; cool blue bars, a single amber break; flat, clean, lots of horizontal whitespace

Rotation buys you theft detection too

Rotation does more than shorten revocation windows. Each refresh exchange invalidates the old refresh token and issues a new one, so the old token should never be seen again. If a stale refresh token is replayed, that's a signal something is wrong — a copy of the token is being used somewhere it shouldn't be, and refresh rotation detects the token theft.

The standard response is to treat a replayed refresh token as a breach of that token family: revoke the entire chain, forcing every client holding a descendant of that token to re-authenticate.

async function refreshWithReuseDetection(refreshToken: string) {
  const record = await store.findRefreshToken(refreshToken);

  // A token we've already rotated away is being replayed.
  // Legitimate clients never do this — assume theft.
  if (record?.revoked) {
    await store.revokeTokenFamily(record.familyId);
    throw new Error('token_reuse_detected');
  }
  // ...normal rotation path
}

That is the payoff that makes rotation worth the added moving parts: you get faster revocation and a tripwire for stolen credentials from the same mechanism.

Where the enterprise pressure comes from

None of this is academic once you sell to enterprises. When a customer deactivates an employee, they expect that person's access to be gone — not gone eventually. Enterprise offboarding runs through SCIM, and the deprovisioning event that fires when an admin removes a user is exactly the moment access is supposed to stop. That makes instant session revocation a compliance requirement, not a nice-to-have.

Think about the sequence. An admin disables a departing employee in Okta or Entra. SCIM sends a deprovisioning event to your app. Your app marks the user inactive. If your sessions are stateless JWTs with hour-long TTLs and no rotation, that former employee keeps hitting your API for up to an hour after IT thinks they're locked out. That gap is what shows up in a security questionnaire, and it's what an auditor asks about.

The SCIM event is the trigger. Short-lived access tokens plus rotation are what make the trigger effective. When the deprovisioning webhook marks a user inactive, the very next refresh attempt fails and the user's access dies within one access-token lifetime. Keep long-lived stateless tokens and you have nothing to enforce against.

IMAGE: An external circle on the left representing an identity provider, connected by a directional arrow to a central rounded rectangle representing the application; from that rectangle a short arrow reaches a small token shape that fades out partway along its path; cool blues for the provider and app, the fading token in muted gray-amber; flat, minimal composition with clear left-to-right flow

The takeaway

Stateless JWTs are the right default until the moment someone needs to be logged out now. Don't abandon them. Keep access tokens short, revoke at the refresh boundary, and wire your SCIM deprovisioning events into that boundary so a disabled user loses access on the next token exchange.

You can build all of this yourself. But session management, refresh rotation, and SCIM deprovisioning are exactly the plumbing that WorkOS AuthKit and Directory Sync handle so you don't have to answer the "how fast is revocation" question by shipping a denylist under deadline pressure.