JWT has a design problem that most developers don't think about until it bites them.
Once you issue a JWT, you can't take it back.
The token is valid until it expires. There's no built-in mechanism to say "this token is no longer valid, reject it." The signature is cryptographically correct, the expiry hasn't passed — and yet you need it to stop working right now.
This isn't a bug. It's a deliberate tradeoff in JWT's stateless design. But it's a tradeoff with real consequences.
The scenarios where this actually hurts
User logs out. The client discards the token, but the token is still cryptographically valid. If someone intercepted it — through a compromised device, a browser extension, a network log — they can still use it until it expires. You have no way to stop them.
Order cancelled. You signed a payment intent with a 5-minute expiry. The user cancels before paying, but the token is still valid for the next 4 minutes and 50 seconds. Anyone holding that token can still attempt to complete the transaction.
Employee offboarded. You revoke their account in your database, but they still have a valid JWT in their API client. Depending on your expiry policy, they might have hours or days of continued access.
Account compromised. You detect suspicious activity and want to lock down the account immediately. You can update a flag in your database, but if you're doing stateless JWT verification, you're not hitting the database on every request — that's the whole point of stateless tokens.
The workarounds developers use (and their problems)
Short-lived tokens with refresh tokens. Set expiry to 15 minutes. Issue a refresh token to get a new access token. This limits the blast radius — a stolen token is only valid for 15 minutes. But it doesn't solve the problem, it just shrinks the window. And it adds complexity: you now have two token types, a refresh endpoint, rotation logic, and a client that needs to handle token refresh transparently.
Token blacklist in Redis or a database. On every verify, check if the token's JTI (JWT ID) is in a blocklist. This works, but you've now reintroduced the stateful system you were trying to avoid. Every verify call hits Redis. You need to manage Redis availability, replication, and eviction policies. You've essentially built your own revocation system on top of a stateless protocol.
Very short expiry without refresh. Set expiry to 60 seconds. Forces frequent re-authentication. Users hate it. The UX is terrible. Only viable for specific use cases like single-use links.
None of these are wrong — they're widely used in production. But they're all workarounds for a missing primitive.
What proper revocation looks like
Revocation should be:
- Immediate — once revoked, the token is rejected on the next verify call, not eventually
- Cryptographically tied — the revocation entry must be tied to the specific token, not just the user or a JTI that can be forged
- Automatic cleanup — revocation entries should expire when the original token would have expired, with no manual cleanup needed
- Idempotent — revoking a token twice should return success without double-charging or erroring
- Rejection of expired tokens — a token that's already expired shouldn't be revokable — that would be revoking something that's already invalid
How FIPSign handles revocation
FIPSign is built on ML-DSA-65 (NIST FIPS 204) — the post-quantum digital signature standard. The token structure includes a payload (base64 JSON), a signature (ML-DSA-65 over the payload), and metadata.
When you revoke a token, the server:
- Verifies the ML-DSA-65 signature — confirms the token is genuine and hasn't expired
- Computes SHA-256 of the full signature and stores it in a revocation table
- Returns the revocation timestamp, the token's
sub, and the original expiry
const result = await pq.revoke(token, 'user logged out')
// result.success → true
// result.revokedAt → Unix timestamp
// result.sub → "user_123"
// result.expiresAt → original token expiry
On every subsequent verify() call, the server checks the revocation table before returning a result:
const { valid, error } = await pq.verify(token)
// valid: false
// error: "Token has been revoked"
A few properties worth noting:
Cryptographically bound. The revocation entry is keyed on a SHA-256 hash of the ML-DSA-65 signature — not a JTI that could be replicated, not the user ID. Two tokens for the same user with the same payload will have different signatures and different revocation entries.
Automatic expiry. Revocation entries are stored with a TTL equal to the remaining lifetime of the original token. When the token would have expired anyway, the revocation entry is cleaned up. No manual maintenance.
Idempotent. Revoking an already-revoked token returns { success: true, message: "Token was already revoked" } without consuming an extra token or throwing an error.
Expired tokens can't be revoked. If a token has already expired, revoke() returns a 400 error. There's no point in revoking something that's already invalid — and accepting expired tokens for revocation would open a vector for abuse.
The cost model
Signing costs 1 token. Verifying costs 1 token. Revoking costs 1 token. There's no separate revocation infrastructure to run — it's the same API.
With 10,000 free tokens per month, a typical session lifecycle — sign on login, verify on each protected request, revoke on logout — is well within the free tier for most applications.
Revocation isn't optional
The pattern of "issue a JWT, let it expire naturally" works in many cases. But any application that handles sessions, financial operations, access control, or sensitive data needs a way to say "this token is no longer valid" — and mean it immediately.
JWT's stateless design makes that genuinely hard. Every workaround has tradeoffs.
Revocation built into the signing primitive — tied to the cryptographic signature itself, with automatic cleanup and no separate infrastructure — is a better model.
That's what we built into FIPSign from day one, not as an add-on, but as a first-class operation alongside sign and verify.
If you want to try it: fipsign.dev — 10,000 free tokens per month, no credit card. SDK for JS/TS and Python, REST API for everything else.
Top comments (0)