Token Blacklist
The blacklist is used to invalidate tokens before they expire, suitable for user logout, password changes, permission changes, etc.
How It Works
Revoke(token) → Extract jti + exp → Write to BlacklistStore
Validate(token) → Verify signature → Check blacklist → Return resultRevoke does not simply write the input string to the blacklist. It first runs a security verification chain to ensure only genuinely issued tokens are revoked:
- Verify signature — re-validates the token signature with the processor's configured key, rejecting any tampered or forged tokens.
- Check issuer and audience — verifies that
issandaudmatch the processor configuration, preventing cross-domain mistaken revocation. - Extract jti — takes the token's unique ID (
jti) as the blacklist key; if the token has nojti, returnsErrTokenMissingID. - Compute TTL and write to storage — calculates the entry's time-to-live based on the token's
exp(see the next section), then writes the jti toBlacklistStore.
Signature Verification Is a Key Security Design
Why does Revoke verify the signature before revoking? If it blindly trusted the caller-supplied jti, a malicious caller could use a forged jti to blacklist any legitimate user's token, launching a denial-of-service attack. Mandatory signature verification guarantees that "only someone holding the real token can revoke it" — which also means that Revoke must be called with the full token string, not a bare jti.
Neither Revoke nor IsRevoked checks exp/nbf: even an expired token can still be revoked or queried for revocation status. This design allows scenarios like auditing and after-the-fact remediation revocation to cover historical tokens.
Blacklist Entry TTL
Blacklist entries do not persist forever. On write, Revoke calculates the entry's time-to-live (TTL) from the token's exp; once the token expires, the entry becomes invalid and is cleaned up. The three cases are:
- Token has an
expclaim — the TTL equals the remaining time untilexp; the entry expires in sync with the token. This is the most common case. - Token has no
expclaim — the TTL defaults to 7 days, preventing non-expiring tokens from occupying entries indefinitely. - TTL is capped at 30 days — even if the token's
expis 100 years out, the blacklist entry can live for at most 30 days.
The 30-Day Cap Is DoS Protection
The 30-day ceiling is a critical safeguard. Without it, an attacker could craft tokens with extremely long exp (or abuse legitimate long-lived tokens) and revoke them in bulk, blowing up the blacklist storage and exhausting memory. With the 30-day cap, every single record has an upper bound on its lifetime, keeping storage scale always controllable.
Additionally, expired tokens can still be revoked: since Revoke does not check exp/nbf, you can retroactively revoke a token after it expires (e.g., when a post-incident audit reveals a risk). Such entries get the default 7-day TTL and are later reclaimed by the background cleanup mechanism.
Built-in Memory Storage
Memory storage is used by default and works out of the box:
cfg := jwt.DefaultConfig()
cfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"
// Blacklist is automatically enabled with DefaultBlacklistConfig()Configuration
cfg.Blacklist.CleanupInterval = 5 * time.Minute // Cleanup interval
cfg.Blacklist.MaxSize = 100000 // Max entries
cfg.Blacklist.EnableAutoCleanup = true // Auto cleanup| Field | Default | Description |
|---|---|---|
CleanupInterval | 5m | Expired entry cleanup interval |
MaxSize | 100000 | Max entries |
EnableAutoCleanup | true | Auto cleanup (forced to true) |
Auto Cleanup
Built-in storage forces EnableAutoCleanup to true, preventing unbounded memory growth.
Eviction Behavior
When the entry count reaches MaxSize, writing a new entry triggers eviction, making room in the following order:
| Step | Behavior | Description |
|---|---|---|
| 1 | Clean expired entries | First deletes all already-expired records |
| 2 | Evict earliest-expiring entries | If still full, evicts approximately 10% (at least 1) of the earliest-expiring entries by ascending exp |
| 3 | Reject the write | If still full, Add returns an error and Revoke accordingly fails |
So MaxSize is not "stop when full"; under pressure it prioritizes evicting the entries that should disappear first (already expired, soonest to expire). In extreme cases revocation may still fail — therefore, in production, consider raising MaxSize based on peak revocation volume, or switching to external storage.
Revoking Tokens
// Revoke
err := processor.Revoke(accessToken)
if err != nil {
panic(err)
}
// Check
revoked, err := processor.IsRevoked(accessToken)
fmt.Println("Revoked:", revoked) // true
// Revoked tokens will fail validation
_, _, err = processor.Validate(accessToken)
// err → jwt.ErrTokenRevokedCustom Storage Backend
Implement the BlacklistStore interface to connect external storage (Redis, databases, etc.):
type BlacklistStore interface {
Add(tokenID string, expiresAt time.Time) error
Contains(tokenID string) (bool, error)
Close() error
}Redis Example
type RedisStore struct {
client *redis.Client
}
func (s *RedisStore) Add(tokenID string, expiresAt time.Time) error {
ttl := time.Until(expiresAt)
if ttl <= 0 {
return nil // Already expired, no need to store
}
return s.client.Set(ctx, "blacklist:"+tokenID, "1", ttl).Err()
}
func (s *RedisStore) Contains(tokenID string) (bool, error) {
n, err := s.client.Exists(ctx, "blacklist:"+tokenID).Result()
return n > 0, err
}
func (s *RedisStore) Close() error {
return s.client.Close()
}Use custom storage:
cfg.Blacklist.Store = &RedisStore{client: rdb}TTL Optimization
Use time.Until(expiresAt) as Redis TTL — tokens are automatically removed from the blacklist after expiration without additional cleanup.
Close() Responsibilities
Processor.Close() cascades a call to BlacklistStore.Close() on shutdown — you do not need to close the blacklist storage manually; closing the processor is enough. A custom storage's Close() implementation should release all underlying resources:
- Close Redis / database connections
- Stop background goroutines and tickers
- Release file handles, etc.
The s.client.Close() in the Redis example above handles connection pool cleanup. Close() should be idempotent — repeated calls must not error (the built-in storage implementation already follows this convention; a second call simply returns nil).
Custom Storage Is Not Bound by CleanupInterval / MaxSize
BlacklistConfig's CleanupInterval, MaxSize, and EnableAutoCleanup apply only to the built-in memory storage. Once you set the Store field to use a custom backend, these three fields are ignored entirely — expiration cleanup, capacity limits, and so on are the responsibility of your storage backend (e.g., Redis TTL, database scheduled jobs).
Production Recommendations
Multi-Instance Deployments Must Share the Blacklist
The built-in memory storage is not shared across processes. If your service runs multiple instances (Pods / containers / servers), a token revoked on one instance will still be accepted on others — a user who logs out is still treated as logged in on a different instance. In multi-instance scenarios, you must use shared storage such as Redis or a database as the BlacklistStore, ensuring all instances read and write the same blacklist.
Monitor Blacklist Size
The blacklist accumulates revocation records until entries expire by TTL. Monitor the storage size (entry count for in-memory storage, key count for Redis) and alert on abnormal growth — a sudden spike usually signals a bulk revocation (e.g., a security incident) or overly long TTLs. Setting MaxSize slightly above peak revocation volume avoids triggering eviction and revocation failures.
Short-TTL Tokens May Not Need a Blacklist
If the access token itself has a very short validity period (e.g., 15 minutes), a "logout" lets the token expire naturally within minutes, so maintaining a blacklist is usually not worth it — the cost of the blacklist (storage + an extra query on every validation) may exceed the benefit. Blacklists are better suited for revoking long-lived tokens (long-lived access tokens, refresh tokens). For short-TTL scenarios, consider enabling the blacklist only for refresh tokens.
Other Considerations
- Custom storage implementations should handle network timeouts and retries to prevent external storage jitter from blocking the validation chain
- Once
MaxSizeis reached, newly revoked tokens evict the oldest entries (see "Built-in Memory Storage" above)
Next Steps
- API Reference → BlacklistStore — Interface definition
- API Reference → BlacklistConfig — Configuration fields
- API Reference → Revoke — Revoke method
- Advanced Examples — Redis blacklist example