Stateless but Still Revocable: Revoking Leaked JWTs
12 min read

Stateless but Still Revocable: Revoking Leaked JWTs

Imagine this scenario: your monitoring system detects a user’s JWT leaking through a misconfigured log, or through XSS in the frontend. That token is still valid for 30 more minutes. The problem is, JWTs are designed to be stateless — once issued, the server doesn’t “remember” the token, and there’s no built-in mechanism to revoke it before exp is reached. This article discusses several practical strategies to kill JWTs already in circulation, from the lightest to the nuclear option, complete with the often-missed security consideration: who can see the token’s contents when you build your own revocation mechanism.

Why JWTs Are Hard to Revoke

JWTs (JSON Web Tokens) are designed so the server doesn’t need to query a database every time it verifies a request. All needed information — user ID, role, expiry time — is already inside the token itself, digitally signed. The server just verifies the signature, no need to ask anyone.

This is great for performance and scalability: services can scale horizontally without a shared session store. But the trade-off is clear — once a token is issued and signed, the server “loses control” over it until the token expires on its own. There’s no “revoke this token” button by default, because verification never touches the database.

sequenceDiagram
    participant Client
    participant API
    participant AuthService

    Client->>API: Request + JWT
    API->>API: Verify signature (no DB lookup)
    API->>API: Check exp claim, check permissions
    API-->>Client: Response

    Note over API: No "check token status in DB" step

Compare with traditional session-based auth, where the server stores the session ID in a database or Redis. Revoking a session is as easy as deleting that row — the next request is immediately rejected. JWTs don’t have this advantage naturally. All JWT revocation strategies are essentially ways to add a bit of “state” back into a system originally designed to be stateless.


Access Tokens vs Refresh Tokens — Briefly

Before getting into strategies, it’s important to understand the two token types usually used together:

Access tokens — short-lived tokens (usually 15–60 minutes), sent in every request to the API, and verified without a DB lookup as explained above. Because their lifetime is short, the impact of a leaked token is naturally limited by time.

Refresh tokens — long-lived tokens (days to weeks), stored in a safer place (httpOnly cookie, secure storage), and only used to exchange for new access tokens when the old one expires. Refresh tokens almost always have their status stored in the database, because this is the point where the system needs long-term control — logout, reuse detection, account revocation.

flowchart LR
    A[Login] --> B[Access Token 15 minutes]
    A --> C[Refresh Token 7 days]
    B -->|expired| D{Refresh Token valid?}
    D -- Yes --> E[New Access Token]
    D -- No --> F[Must log in again]

The crucial point: revoking the refresh token doesn’t automatically kill still-alive access tokens. If an access token is stolen and still has 30 minutes left, revoking the refresh token in the database has no effect until that access token itself expires — because access token verification never touches the refresh token table. This is a common misunderstanding making teams think their system is safe when the gap is still open for the rest of the access token’s lifetime.

  • Revoking the refresh token prevents new tokens from being issued, but doesn’t kill already-circulating access tokens.
  • If the access token was stolen, you need a separate mechanism to kill it faster than exp.
  • Always assume an attacker can use a stolen access token until the last second of its validity, unless there’s an active mechanism stopping them.

Strategy 1: jti-Based Denylist

The most common and cheapest way to kill a specific access token is a denylist (blocklist). The idea is simple: store the identity of the token to revoke in a fast store like Redis, then check its existence on every request.

The key is the jti (JWT ID) claim — a unique UUID inserted when the token is issued, specifically to identify that token without carrying any sensitive data.

// JWT claim structure with jti
type AccessTokenClaims struct {
	UserID string `json:"sub"`
	Role   string `json:"role"`
	jwt.RegisteredClaims
}

func GenerateAccessToken(userID, role string, secret []byte) (string, error) {
	claims := AccessTokenClaims{
		UserID: userID,
		Role:   role,
		RegisteredClaims: jwt.RegisteredClaims{
			ID:        uuid.NewString(), // this becomes the jti
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
		},
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(secret)
}

When a token needs to be revoked — for example after being detected as leaked — store its jti in Redis with a TTL exactly matching the token’s remaining lifetime. This matters so the Redis entry automatically disappears once the token itself expires, no manual cleanup process needed.

func RevokeToken(ctx context.Context, rdb *redis.Client, claims *AccessTokenClaims) error {
	remaining := time.Until(claims.ExpiresAt.Time)
	if remaining <= 0 {
		return nil // already expired, no need to denylist
	}

	key := fmt.Sprintf("denylist:jti:%s", claims.ID)
	return rdb.Set(ctx, key, 1, remaining).Err()
}

The verification middleware then adds one step: after the signature is valid, check whether its jti is in the denylist.

func AuthMiddleware(rdb *redis.Client, secret []byte) gin.HandlerFunc {
	return func(c *gin.Context) {
		tokenStr := extractBearerToken(c)

		claims := &AccessTokenClaims{}
		_, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
			return secret, nil
		})
		if err != nil {
			c.AbortWithStatusJSON(401, gin.H{"error": "invalid token"})
			return
		}

		// ANTI-PATTERN: trusting the token immediately after a valid signature
		// c.Next()

		// CORRECT: check the denylist before continuing the request
		key := fmt.Sprintf("denylist:jti:%s", claims.ID)
		exists, err := rdb.Exists(c.Request.Context(), key).Result()
		if err != nil {
			c.AbortWithStatusJSON(500, gin.H{"error": "internal error"})
			return
		}
		if exists > 0 {
			c.AbortWithStatusJSON(401, gin.H{"error": "token revoked"})
			return
		}

		c.Set("userID", claims.UserID)
		c.Next()
	}
}

This approach adds one round-trip to Redis per request. For most applications, this added latency (usually under 1ms for local/regional Redis) is far cheaper than the risk of a leaked token staying valid.

If you’re worried about the lookup cost on every request, cache the “clean token” result in local memory with a short TTL (for example 5 seconds), so not every request round-trips to Redis. This is a small trade-off between revoke speed and efficiency — a 5-second delay is still far better than 30 minutes.

Strategy 2: Token Version / Security Stamp

A jti-based denylist suits revoking one specific token. But if the scenario is “this user’s account is suspected compromised, kill all its active sessions”, the more appropriate approach is token versioning.

The method: store a version number (token_version) in the user record in the database, then embed that number as a claim in every access token issued for that user.

type AccessTokenClaims struct {
	UserID       string `json:"sub"`
	TokenVersion int    `json:"tver"`
	jwt.RegisteredClaims
}

func GenerateAccessToken(user *User, secret []byte) (string, error) {
	claims := AccessTokenClaims{
		UserID:       user.ID,
		TokenVersion: user.TokenVersion, // taken from the DB at login
		RegisteredClaims: jwt.RegisteredClaims{
			ID:        uuid.NewString(),
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
		},
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(secret)
}

To kill all tokens belonging to a user, just increment token_version in the database:

func RevokeAllUserTokens(ctx context.Context, db *sql.DB, userID string) error {
	_, err := db.ExecContext(ctx,
		`UPDATE users SET token_version = token_version + 1 WHERE id = $1`,
		userID,
	)
	return err
}

The middleware needs to compare the tver in the token with the current value in the database (or cache):

func AuthMiddlewareWithVersion(userStore UserStore, secret []byte) gin.HandlerFunc {
	return func(c *gin.Context) {
		claims := &AccessTokenClaims{}
		// ... parse the token as before ...

		currentVersion, err := userStore.GetTokenVersion(c.Request.Context(), claims.UserID)
		if err != nil {
			c.AbortWithStatusJSON(500, gin.H{"error": "internal error"})
			return
		}

		// ANTI-PATTERN: not checking the version at all
		// if false { ... }

		// CORRECT: tokens with an old version are automatically rejected
		if claims.TokenVersion != currentVersion {
			c.AbortWithStatusJSON(401, gin.H{"error": "session invalidated"})
			return
		}

		c.Next()
	}
}

The difference from the per-jti denylist: this strategy kills all tokens issued before the increment happened, without needing to know each token’s jti one by one. Suitable for cases like “user changed password”, “account suspected broadly compromised”, or a “log out from all devices” button.

The downside: GetTokenVersion needs a database or cache lookup per request — similar cost to the denylist, but with per-user granularity instead of per-token. Many production systems use a combination of both: token versioning for account-wide cases, jti denylists for specific tokens known to be leaked.


Strategy 3: Refresh Token Revocation

For refresh tokens, revocation is far simpler because refresh tokens are indeed designed to be validated through the database on every use — unlike access tokens, which are validated without a DB lookup.

type RefreshToken struct {
	ID        string
	UserID    string
	TokenHash string // store a hash, not the raw token
	ExpiresAt time.Time
	Revoked   bool
}

func RevokeRefreshToken(ctx context.Context, db *sql.DB, tokenID string) error {
	_, err := db.ExecContext(ctx,
		`UPDATE refresh_tokens SET revoked = true WHERE id = $1`,
		tokenID,
	)
	return err
}

func ValidateRefreshToken(ctx context.Context, db *sql.DB, rawToken string) (*RefreshToken, error) {
	hash := sha256.Sum256([]byte(rawToken))
	var rt RefreshToken
	err := db.QueryRowContext(ctx,
		`SELECT id, user_id, expires_at, revoked FROM refresh_tokens
		 WHERE token_hash = $1`,
		hex.EncodeToString(hash[:]),
	).Scan(&rt.ID, &rt.UserID, &rt.ExpiresAt, &rt.Revoked)
	if err != nil {
		return nil, err
	}
	if rt.Revoked || time.Now().After(rt.ExpiresAt) {
		return nil, errors.New("refresh token invalid")
	}
	return &rt, nil
}

As mentioned in the opening section, revoking the refresh token prevents new access tokens from being issued, but access tokens already in an attacker’s hands stay alive until their own exp. Therefore, if the incident is a leaked access token (not a refresh token), strategies 1 or 2 must still be run alongside. Revoking the refresh token alone without other strategies gives a false sense of security.

Don’t store refresh tokens in plaintext in the database. If the database leaks, attackers get ready-to-use tokens for all users. Store a hash of it (SHA-256 is sufficient for this case because refresh tokens are already random and long), then compare the hash during validation.

Strategy 4: Signing Key Rotation

This is the most extreme option: replacing the secret or key pair used to sign JWTs. Once the key is changed, all tokens ever issued with the old key immediately fail signature verification — including tokens belonging to users completely uninvolved in the incident.

// Key rotation usually involves a transition period
// with multiple valid keys, identified via the "kid" header

func VerifyWithKeyRotation(tokenStr string, keyStore KeyStore) (*jwt.Token, error) {
	return jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
		kid, ok := t.Header["kid"].(string)
		if !ok {
			return nil, errors.New("missing kid header")
		}
		// Look up the public key by kid — old keys that have been
		// revoked won't be found in the keyStore anymore
		key, err := keyStore.GetKey(kid)
		if err != nil {
			return nil, errors.New("key revoked or unknown")
		}
		return key, nil
	})
}

Key rotation makes sense when the scenario is the signing secret itself leaking, or a major breach suspecting many tokens at once. For one token stolen via XSS or a misconfigured log, key rotation is overkill — its impact forces all active users to log in again, not just the one affected account.


Strategy Comparison

StrategyEffective SpeedBlast RadiusComplexity
jti denylistInstantOne specific tokenLow — needs Redis + 1 claim field
Token VersionInstantAll tokens of one userMedium — needs a DB column + per-request lookup
Refresh Token RevocationInstant for new tokens, no impact on old access tokensOne user (future tokens)Low — natural because refresh tokens are already DB-validated
Signing Key RotationInstantAll active usersHigh — needs a key transition strategy, deployment coordination

A common production combination: jti denylists for individual leaked token cases, token versioning for the “log out all devices” button or account incidents, and key rotation kept as an emergency procedure if the signing secret itself leaks.


Securing the Denylist from the Internal Team

There’s one trap often missed when building a revocation mechanism: what you store inside the denylist itself. If you store the raw JWT as a key or value in Redis, anyone with access to that Redis — ops teams, other services sharing the same instance, even monitoring dashboards — can read the entire token claim contents, and in the worst case, use them for requests while the token is still valid.

// ANTI-PATTERN: storing the raw token as the key
key := fmt.Sprintf("denylist:%s", rawTokenString)
rdb.Set(ctx, key, 1, remaining)
// Anyone who can read Redis can see this JWT in full

// CORRECT: store only the jti, not the token itself
key := fmt.Sprintf("denylist:jti:%s", claims.ID)
rdb.Set(ctx, key, 1, remaining)
// jti is just a random UUID, carrying no information about the user

If your system doesn’t have a jti claim at all and must identify tokens by their contents, don’t store the raw token — store its one-way hash:

func denylistKeyFromToken(rawToken string) string {
	hash := sha256.Sum256([]byte(rawToken))
	return fmt.Sprintf("denylist:hash:%s", hex.EncodeToString(hash[:]))
}

A hash can’t be reversed to get the original token, but it can still be matched — you hash the incoming token, then compare it with the stored key.

Several additional steps worth applying:

  • Limit access to the denylist store via ACLs. Redis supports per-user ACLs; make sure only the auth service can read/write the denylist:* namespace, not the entire internal team or other services that happen to share the same Redis instance.
  • Never log raw tokens. This is the most common leak, and it’s actually not from the denylist mechanism itself — but from access logs, APM tracing, or error reporting that unintentionally print the full Authorization header.
  • Redact tokens in observability tools. If you use an APM like Datadog or New Relic, make sure the auth middleware doesn’t forward the Authorization header to span attributes without redaction.
A well-built revocation mechanism that stores raw tokens in a place accessible to many people effectively creates a new gap: you’ve successfully killed the token on the normal verification side, but you’ve opened a new path for anyone with access to that store to see or even abuse the same token before its revocation is processed.

Summary

  • JWTs are stateless by design — there’s no built-in revoke button, all revocation strategies essentially add a bit of state back into the system.
  • jti denylists are the cheapest way to kill one specific token, with a TTL matching the token’s remaining lifetime for self-cleaning.
  • Token versioning suits killing all tokens of one user at once — used for “log out all devices” cases or suspected compromised accounts.
  • Revoking the refresh token prevents new access tokens from being issued, but doesn’t kill already-circulating access tokens still within their validity.
  • Signing key rotation is the emergency option killing all tokens of all users — only sensible if the signing secret itself leaked.
  • Don’t store raw JWTs inside the denylist mechanism — store the jti or a token hash, limit access via ACLs, and never log raw tokens in access logs or APM.
  • The most common production combination: jti denylists for individual token incidents, token versioning for account incidents, key rotation kept as an emergency procedure for major breaches.

Portfolio