JWT Blacklist Strategies: Redis, Bloom Filter, DynamoDB, and Distributed Designs
Last updated:
A JWT blacklist (or denylist) is the small piece of server-side state that puts the "log out" button back into a stateless authentication system. This guide is the implementation deep dive — store-by-store code for Redis SETEX, Bloom filter probabilistic denylists, DynamoDB TTL tables, and Postgres partial indexes, plus the propagation patterns that keep a multi-region deployment in sync. For the broader decision of whether a blacklist is the right approach in the first place — versus token versioning, short-lived access tokens, or full session storage — read our JWT revocation strategies overview instead. The numbers, code, and TTL math below assume you have already decided that a blacklist is the right shape for your system.
Need to inspect a JWT's jti or exp claim while building the blacklist key? Paste any token into Jsonic's JWT Decoder — header, payload, and signature shown in plain JSON without sending the token anywhere.
When you need a blacklist (and when token versioning is better)
A blacklist is the right shape when revocations are per-token rather than per-user. The user has five active sessions across phones and laptops, one device is stolen, and you want to kill that one session without forcing the other four to re-authenticate. The blacklist holds one entry — the jti of the stolen token — and the other four tokens keep working until their natural expiry.
Token versioning is the better shape when revocations are per-user. You bump a tokenVersion integer on the user record, embed that integer in every new token at issuance, and reject any token whose embedded version is lower than the stored one. A single database write kills every active session for that user. No blacklist needed. Versioning is faster (one read per request, cacheable on the user object) and uses no extra memory per token.
Mixed systems use both: versioning for the common case (log out everywhere, password change, account compromise) and a blacklist for the narrow case (revoke this one session, ban this one device). The blacklist stays small because 90% of revocations go through versioning.
The rule of thumb: short access token lifetimes shrink the blacklist automatically. With a 15-minute exp, the worst case is 15 minutes of revocation volume in memory. Combine short access tokens with proper refresh token rotation and the blacklist becomes nearly free. See our broader JWT best practices guide for context on lifetimes and rotation cadence.
Redis-based blacklist: SETEX with TTL = token expiry
Redis is the default choice when latency matters. The hot path is two commands: one SET with an expiry at revocation time, one GET on every authenticated request. Both run in sub-millisecond time against a local or VPC-local Redis instance.
The SET command (with the EX option) is the modern equivalent of the older SETEX — same behavior, single round trip: SET key value EX seconds. It writes the key with a TTL in one operation, which is what you want when the TTL must always be set to avoid accidental permanent entries.
// blacklist.ts — Node.js with ioredis
import Redis from 'ioredis'
import { jwtVerify, decodeJwt } from 'jose'
const redis = new Redis(process.env.REDIS_URL!)
const PREFIX = 'revoked:'
export async function revokeToken(token: string): Promise<void> {
const { jti, exp } = decodeJwt(token)
if (!jti || !exp) throw new Error('token missing jti or exp claim')
const ttlSeconds = Math.max(0, exp - Math.floor(Date.now() / 1000))
if (ttlSeconds === 0) return // already expired, nothing to do
// SET with EX in one round trip — replaces the older SETEX
await redis.set(`${PREFIX}${jti}`, '1', 'EX', ttlSeconds)
}
export async function isRevoked(jti: string): Promise<boolean> {
const value = await redis.get(`${PREFIX}${jti}`)
return value !== null
}
// Auth middleware integration
export async function verifyAndCheckBlacklist(token: string, secret: Uint8Array) {
const { payload } = await jwtVerify(token, secret)
if (!payload.jti) throw new Error('token missing jti')
if (await isRevoked(payload.jti)) throw new Error('token revoked')
return payload
}The TTL math is the load-bearing part. exp - now in seconds gives the remaining lifetime; the Redis entry vanishes exactly when the token would have stopped being valid on its own. Misalignment in either direction is a bug — too long wastes memory, too short lets a revoked token start working again before itsexp claim.
For deeper Redis patterns including pipelining, Lua scripts, and connection pooling, see our JSON in Redis guide. The same connection management rules apply to blacklist traffic — reuse a single client across requests, configure a sane maxRetriesPerRequest, and alert on connection errors before they cascade.
Bloom filter: memory-efficient probabilistic denylist
A Bloom filter is the right tool when the blacklist is huge, most requests use non-revoked tokens, and you want to skip the network call entirely for the common case. The filter answers "definitely not revoked" or "maybe revoked" — never a false negative. False positives are bounded by the bits-per-key setting.
At 0.1% false positive rate the cost is roughly 9.6 bits per key. One million entries fit in about 1.2 MB. Ten million in 12 MB. The filter sits in process memory and serves the fast path; on a "maybe" hit, you fall through to the authoritative store (Redis or DynamoDB) to confirm.
# bloom_blacklist.py — Python with bloom-filter2
from bloom_filter2 import BloomFilter
import redis
import os
# Tune: 1M expected entries, 0.001 false positive rate -> ~1.2 MB
bloom = BloomFilter(max_elements=1_000_000, error_rate=0.001)
r = redis.from_url(os.environ['REDIS_URL'])
def add_to_bloom(jti: str) -> None:
bloom.add(jti)
def is_revoked_fast(jti: str) -> bool:
"""
Returns True if the token is revoked.
Two-tier check: Bloom filter first (fast), Redis second (authoritative).
"""
if jti not in bloom:
return False # definitely not revoked — skip network entirely
# Bloom said "maybe" — confirm against Redis
return r.get(f'revoked:{jti}') is not None
def revoke(jti: str, ttl_seconds: int) -> None:
r.set(f'revoked:{jti}', '1', ex=ttl_seconds)
add_to_bloom(jti)
# Rebuild the Bloom filter on a schedule to drop expired entries.
# Standard Bloom filters do not support deletion; rebuild from Redis SCAN.
def rebuild_bloom() -> None:
global bloom
new_bloom = BloomFilter(max_elements=1_000_000, error_rate=0.001)
for key in r.scan_iter(match='revoked:*', count=1000):
jti = key.decode().split(':', 1)[1]
new_bloom.add(jti)
bloom = new_bloomThe standard Bloom filter has one limitation: you cannot remove individual entries. A revoked token that expires before the filter rebuild still reads as "maybe revoked" — which is fine because the fallback Redis check will return null and the request proceeds. The filter only adds false positives, never false negatives, so correctness holds.
If you need true deletion before the rebuild interval, use a counting Bloom filter — each slot is a small counter instead of a bit, supporting both addition and removal at roughly 4x the memory cost. For most JWT blacklist workloads, scheduled rebuilds every few minutes are simpler.
DynamoDB TTL blacklist for serverless deployments
DynamoDB is the right answer when your stack is Lambda or Vercel Functions and you do not want to operate Redis. Pay-per-request pricing means a low-volume blacklist (most apps) costs a few dollars a month. The built-in TTL feature auto-deletes expired rows asynchronously, so capacity planning stays simple.
The schema is one item per revoked token. Partition key is the jti. The TTL attribute holds the exp claim as an epoch second. DynamoDB scans the TTL attribute in the background and removes expired items within 48 hours of expiry — close enough for blacklist purposes because the application still checks exp in the JWT itself.
// dynamo-blacklist.ts — AWS SDK v3 with DynamoDB DocumentClient
import { DynamoDBClient } from '@aws-sdk/client-dynamodb'
import { DynamoDBDocumentClient, PutCommand, GetCommand } from '@aws-sdk/lib-dynamodb'
const client = DynamoDBDocumentClient.from(new DynamoDBClient({}))
const TABLE = 'jwt-blacklist'
export async function revoke(jti: string, expEpochSeconds: number) {
await client.send(new PutCommand({
TableName: TABLE,
Item: {
jti, // partition key
expiresAt: expEpochSeconds, // TTL attribute — DynamoDB auto-deletes after this epoch
revokedAt: Math.floor(Date.now() / 1000),
},
}))
}
export async function isRevoked(jti: string): Promise<boolean> {
const { Item } = await client.send(new GetCommand({
TableName: TABLE,
Key: { jti },
ConsistentRead: false, // eventual consistency is fine; saves cost
}))
if (!Item) return false
// Defensive double-check: DynamoDB TTL deletion can lag up to 48h
return Item.expiresAt > Math.floor(Date.now() / 1000)
}Table setup: create the table with jti as the partition key, then enable TTL on the expiresAt attribute through the AWS console or with the UpdateTimeToLive API. Once enabled, DynamoDB consumes no write capacity for the deletions — they happen in the background.
The defensive double-check in isRevoked matters because TTL deletion is asynchronous. An item past its expiresAt may linger for minutes or hours before DynamoDB sweeps it. Compare against the current epoch in your code so a half-deleted item does not produce a false positive.
For high read volume, front DynamoDB with DAX or an application-level cache. The read path then becomes: in-process cache, DAX, DynamoDB — three tiers with graceful fallback.
PostgreSQL blacklist with partial indexes for high-throughput
Postgres is the right choice when the blacklist is small (under a few hundred thousand live entries) and you already run Postgres for the rest of the application. It avoids adding a new datastore, transactions give you atomic revocation across multiple related tables, and partial indexes keep the read path fast.
-- schema.sql
CREATE TABLE jwt_blacklist (
jti UUID PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
reason TEXT
);
-- Partial index: only rows that are still live count for index scans.
-- Expired rows still sit in the table but are skipped by the index,
-- so lookups stay fast even before the cleanup job runs.
CREATE INDEX idx_jwt_blacklist_live
ON jwt_blacklist (jti)
WHERE expires_at > NOW();
-- Scheduled cleanup — run hourly via pg_cron or an application job
DELETE FROM jwt_blacklist WHERE expires_at < NOW() - INTERVAL '1 hour';The partial index is the key optimization. A plain index on jti grows linearly with total revocations including the expired ones still waiting for cleanup. The partial index — WHERE expires_at > NOW() — only includes live rows. As rows age past their expires_at, the index drops them automatically. Lookups stay fast even when the cleanup job has not run for a day.
One subtlety: NOW()in a partial index predicate is not immutable from Postgres's point of view. A universally safe alternative is a two-column index (jti, expires_at) with the cutoff in the query — index-only scans handle thousands of lookups per second on a small RDS instance.
Distributed blacklist: gossip vs central authority vs streaming
Once you run more than one region or more than one cluster, the blacklist has a propagation problem. A token revoked in us-east-1 must be rejected in eu-west-1 too. There are three working patterns and one anti-pattern.
Central authority: every region reads from a single primary store (a Redis cluster with cross-region replicas, DynamoDB Global Tables, or a multi-region Postgres). Simple to reason about and strongly consistent in the global-tables case. The cost is auth-path latency — cache misses cross regions — and a single failure domain for the entire blacklist.
Gossip: each region runs its own local store. A small background process exchanges revocation events between regions every few seconds. Latency on the auth path stays sub-millisecond because every read is local. The trade-off is eventual consistency — a token revoked in one region may still work in another for a few seconds during propagation. Acceptable for most products; risky for high-stakes systems where seconds matter.
Streaming: revocations are published to a durable, ordered stream (Kafka, Redis Streams, AWS Kinesis, NATS). Each region runs a consumer that applies events to its local store. This is the most common production pattern at scale. Properties: durable (events replay on consumer restart), ordered (no race conditions on rapid revoke-then-reinstate sequences), and fan-out is configured once at the stream level.
// kafka-producer.ts — publishing revocation events
import { Kafka, CompressionTypes } from 'kafkajs'
const kafka = new Kafka({ clientId: 'auth-api', brokers: process.env.KAFKA_BROKERS!.split(',') })
const producer = kafka.producer({ allowAutoTopicCreation: false })
await producer.connect()
export async function publishRevocation(jti: string, expEpochSeconds: number) {
await producer.send({
topic: 'jwt.revocations',
compression: CompressionTypes.GZIP,
messages: [{
key: jti, // partition by jti for ordering on the same token
value: JSON.stringify({
jti,
exp: expEpochSeconds,
revokedAt: Math.floor(Date.now() / 1000),
reason: 'logout',
}),
}],
})
}
// Consumer in each region applies events to local Redis
// (sketch — full consumer would handle commits, retries, dead letters)
const consumer = kafka.consumer({ groupId: `blacklist-applier-${process.env.REGION}` })
await consumer.subscribe({ topic: 'jwt.revocations', fromBeginning: false })
await consumer.run({
eachMessage: async ({ message }) => {
const event = JSON.parse(message.value!.toString())
const ttl = Math.max(0, event.exp - Math.floor(Date.now() / 1000))
if (ttl > 0) await localRedis.set(`revoked:${event.jti}`, '1', 'EX', ttl)
},
})The anti-pattern: writing to every region directly from the API. It looks like the simplest option, but partial failures (one region's write succeeded, another timed out) leave the system in an inconsistent state with no replay path. Use a stream instead — the broker handles retry, ordering, and replay.
Read path: cache-first, fall back to DB
The auth middleware runs on every authenticated request, so the blacklist check has to be cheap. The pattern that scales is two or three tiers with graceful fallback: in-process cache, Redis, then the authoritative store. Each tier absorbs the load below it.
// blacklist-tiered.ts — local cache + Redis fallback
import LRU from 'lru-cache'
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)
// Local cache: 10k entries, 60s TTL.
// Caches both positive ("revoked") and negative ("not revoked") results.
const localCache = new LRU<string, boolean>({
max: 10_000,
ttl: 60_000, // 60 seconds
})
export async function isRevoked(jti: string): Promise<boolean> {
// Tier 1: in-process cache
const cached = localCache.get(jti)
if (cached !== undefined) return cached
// Tier 2: Redis
const value = await redis.get(`revoked:${jti}`)
const revoked = value !== null
// Cache the result. Negative results cached for 60s; positive cached
// until the entry would expire in Redis (here we just use 60s — close enough).
localCache.set(jti, revoked)
return revoked
}Why cache the negative result too: the common case is "not on the blacklist," and that is the result that benefits most from caching. Without negative caching, every legitimate request hits Redis. With it, a single Redis call serves up to 60 seconds of repeated requests for the same token — which is the typical shape when one user holds one token for the lifetime of a session.
The 60-second window is the failure budget. If you revoke a token, the worst case is that other server processes keep accepting it for up to 60 seconds while their local caches expire. Shorten the TTL for high-stakes systems; extend it for high-volume systems where staleness matters less than throughput. Patterns like this are the same shape as rate limiting patterns — both tier a fast in-process check in front of a slower authoritative store.
For systems where any staleness is unacceptable, use a Redis pub/sub channel: when a revocation is written, publish the jti on a channel and have every server invalidate its local cache entry. The local cache becomes a true hot tier rather than a TTL-bounded best-effort cache.
Blacklist eviction: TTL alignment with refresh window
The single most important invariant for a blacklist: the entry must live exactly as long as the token would have been valid. Both ends matter.
Too short: the entry expires before the token does. A revoked but unexpired token starts working again — a security bug where the revocation silently failed. Common cause: a fixed TTL (1 hour) applied to tokens with longer remaining lifetimes.
Too long: the entry outlives the token. The token is already rejected by the exp check, so the entry adds nothing — memory wasted with no correctness issue. The rule: blacklist_ttl = max(0, token.exp - now) in seconds, computed at revocation time from the actual exp claim. Every code sample above follows this.
Refresh token interplay: if your access tokens are short-lived (15 minutes) and refresh tokens are long-lived (30 days), the blacklist for access tokens stays tiny — 15 minutes of revocation volume at any moment. Blacklisting refresh tokens is different: those entries live for up to 30 days, so memory cost scales differently. Most systems blacklist refresh tokens by user ID with aversion rather than by token identity, exactly because the per-token approach gets expensive at long lifetimes.
Sliding-window refresh tokens: if every API call extends the refresh token expiry, the blacklist TTL must extend too. The simplest fix is to rewrite the blacklist entry with a fresh TTL on each extension. Otherwise, the entry expires while the token is still being extended in the background. See our refresh token rotation guide for the full lifecycle, and our JWT storage guide for how the storage choice affects which tokens you actually need to be able to revoke.
Key terms
- JWT blacklist (denylist)
- A server-side record of token identifiers that should be rejected even when their signature is valid and
expclaim has not passed. Checked on every authenticated request in the auth middleware. - jti claim
- JWT ID — a unique identifier assigned to each token at issuance (RFC 7519). The preferred blacklist key because it is short, stable, and safe to log without exposing the bearer credential.
- SETEX / SET ... EX
- Redis command that writes a key with a TTL in one round trip.
SET key value EX secondsis the modern equivalent of the olderSETEXcommand — same behavior, single network call. - Bloom filter
- A probabilistic data structure that answers set membership queries with no false negatives and a tunable false positive rate (commonly 0.1% at ~9.6 bits per element). Used to skip the authoritative store on the common "not revoked" case.
- DynamoDB TTL
- A DynamoDB feature that auto-deletes items based on an epoch-second attribute. Enabled per table per attribute via
UpdateTimeToLive. Deletions happen asynchronously, typically within 48 hours of expiry. - partial index
- A PostgreSQL index that includes only rows matching a predicate (e.g.,
WHERE expires_at > NOW()). Keeps the index small even when the underlying table accumulates expired rows pending cleanup. - TTL alignment
- The invariant that a blacklist entry's lifetime must equal the remaining lifetime of the token it revokes. Misalignment causes either silent revocation failures (too short) or wasted memory (too long).
Frequently asked questions
What is a JWT blacklist?
A JWT blacklist (also called a denylist or revocation list) is a server-side record of tokens that should be rejected even though their signature is still valid and their expiry has not passed. Once a token is on the list, the auth middleware refuses to honor it. The list exists because JWTs are stateless by design — a verified signature plus an unexpired exp claim is normally enough to accept a request. That property is great for performance but bad when you need to log a user out, react to a stolen token, or kill an admin session. The blacklist puts a small piece of state back into the auth path so revocation becomes possible. The trade-off is that every request now incurs at least one cache lookup. Most production systems accept that cost in exchange for the ability to invalidate individual tokens on demand.
When should I use Redis vs DynamoDB for JWT blacklist?
Pick Redis when you already run Redis for sessions, rate limiting, or caching and you want sub-millisecond GET latency on the auth path. SETEX gives you TTL eviction for free, memory cost is predictable, and the API is two commands. Pick DynamoDB when your stack is serverless (Lambda, Vercel Functions) and you want to avoid managing a Redis cluster — DynamoDB scales horizontally without operator effort, supports per-item TTL that auto-deletes expired entries, and prices on a per-request basis that is cheap at low volume. Latency is higher than Redis (single-digit milliseconds vs sub-millisecond) but acceptable for most APIs. Hybrid is common: DynamoDB as the source of truth for durable revocations, a small in-process cache or Redis tier in front for read amplification. Avoid Postgres for the hot path unless the blacklist is small and you already have a partial index strategy.
How does a Bloom filter blacklist work?
A Bloom filter is a probabilistic data structure that answers one question: "have I seen this key before?" It can give two answers — definitely not, or maybe yes. False negatives are impossible; false positives happen at a rate you tune (commonly 0.1%, costing about 9.6 bits per key). For a blacklist, the filter sits in memory in front of the real store. On a request, you hash the token jti and check the filter. If the filter says "definitely not on the blacklist," you accept the token without hitting Redis or the database. If it says "maybe," you fall through to the authoritative store to confirm. At 0.1% false positive rate, 99.9% of valid tokens skip the network call entirely — huge throughput win. The cost is that you must rebuild the filter on a schedule or use a counting Bloom variant if you need to remove entries before they expire.
What's the memory cost of a JWT blacklist at scale?
For a Redis blacklist keyed by jti, the math is straightforward: each entry stores a key (32-byte jti as a UUID), a one-byte value, plus roughly 32 bytes of Redis overhead per key — call it 64 bytes per entry total. One million revoked tokens consume about 64 MB. Ten million is around 640 MB. The cost stays bounded because TTL evicts entries when the token would have expired anyway — a blacklist for tokens with a 15-minute exp will never grow past 15 minutes of revocation volume. A Bloom filter cuts memory hard: 1M entries at 0.1% false positive rate fit in about 1.2 MB. DynamoDB pricing is per-item-per-month rather than RAM; at typical revocation volumes (logouts, password resets) the monthly bill is dollars, not hundreds of dollars. Postgres rows with a partial index on expires_at are a few hundred bytes each — fine for small blacklists, expensive once you cross a million live rows.
How do I sync a blacklist across multiple regions?
You have three patterns. Central authority — a single primary store (Redis with replicas, DynamoDB Global Tables, or a multi-region database) that every region reads. Simple but the auth path crosses regions on cache miss, adding latency. Gossip — each region runs its own local store and they exchange revocation events on a periodic schedule (every few seconds). Lowest latency on the hot path but eventually consistent; a token revoked in one region may still work in another for a brief window. Streaming — publish revocation events to Kafka, Redis Streams, or AWS Kinesis, and every region consumes the stream and updates its local store. This is the most common production pattern: durable, eventually consistent in seconds, and the consumer in each region keeps a hot local cache. Pick streaming when you need both low auth-path latency and bounded inconsistency.
Should I blacklist by jti or by token hash?
Blacklist by jti when you control issuance and assign a unique jti claim to every token at creation. The jti is short (a UUID, 16 bytes raw or 36 bytes as a string), well-defined, and survives any signature changes. Blacklist by a hash of the full token (typically SHA-256) when tokens were issued without a jti or when you want to revoke specific token strings rather than logical token identities. The hash approach also works if your token contains no stable identifier you trust. The jti path is preferred for new systems: shorter keys, faster lookups, and the jti can be logged in audit trails without exposing the bearer. The hash path is the migration path for older systems where you cannot reissue tokens. Never use the raw token as the key — even on the server side, that is essentially storing a credential.
What happens to the blacklist when a token expires?
The blacklist entry should disappear at the same moment the token would have stopped being valid on its own. There is no reason to keep a record of a revoked token after its exp claim passes — the auth middleware already rejects it on the expiry check. Use the store's native TTL feature to align the two. Redis: SETEX revoked:<jti> <seconds_until_exp> 1. DynamoDB: write an item with an attribute equal to the epoch second of exp and enable TTL on that attribute — DynamoDB removes the row asynchronously after the timestamp. Postgres: store expires_at and run a scheduled DELETE WHERE expires_at < NOW(), or use a partial index that ignores expired rows. The alignment matters because misaligned TTL either keeps dead data (wastes memory) or evicts entries early (a revoked but unexpired token would silently start working again).
Can I use Redis Streams or Kafka for blacklist propagation?
Yes — streaming is the canonical pattern for distributed blacklist propagation. The flow is: when an admin or user triggers a revocation, the API writes the jti and TTL to a stream (Kafka topic, Redis Stream, or AWS Kinesis shard) instead of writing directly to every regional store. Each region runs a consumer that subscribes to the stream, applies revocations to its local Redis or in-memory cache, and updates a local Bloom filter for the fast path. Streaming gives you durable, ordered delivery, easy fan-out to N regions, and a built-in replay log when a region needs to rebuild its blacklist after a restart. The trade-off is operational — you now run Kafka or Kinesis alongside your auth service. For single-region deployments, direct writes are simpler. For multi-region or multi-cluster setups, streaming is the right shape.
Further reading and primary sources
- RFC 7519 — JSON Web Token — The jti claim definition and all standard JWT claims
- Redis SET command — Authoritative reference for SET with EX, PX, NX, and XX options
- DynamoDB Time To Live — Per-item TTL configuration and operational behavior
- OWASP JWT Cheat Sheet — Security considerations for token revocation and blacklist design
- Bloom Filters by Example — Visual walkthrough of Bloom filter mechanics and false-positive math