JWT Blacklist ile Token İptal Yönetimi
JWT’nin en büyük paradoksu şu: stateless olması hem en güçlü özelliği hem de en büyük zayıflığı. Token bir kez imzalanıp istemciye gönderildi mi, sunucu tarafında onu “unutmak” pek kolay değil. Kullanıcı çıkış yaptı, hesabı askıya alındı, şifresi değişti… ama token hâlâ geçerli. İşte bu yüzden JWT blacklist yönetimi, production ortamlarında kaçınılmaz bir konu haline geliyor.
JWT İptal Problemi Neden Var?
Klasik session tabanlı kimlik doğrulamada kullanıcıyı logout etmek trivial: server-side session’ı sil, bitti. JWT’de ise token’ın kendisi tüm bilgiyi taşıyor ve sunucu genellikle bir state tutmuyor. Token’ın expiry süresi dolana kadar teknik olarak geçerli.
Gerçek dünyadan bir senaryo düşün: Bir çalışanın kurumsal sisteme erişim tokenı ele geçirildi. Güvenlik ekibi durumu fark etti ve hesabı devre dışı bıraktı. Ama token’ın expire süresi 24 saat. O 24 saat boyunca saldırgan sisteme erişmeye devam edebilir.
Bu sorunu çözmenin birkaç yolu var:
- Kısa token süresi: Access token’ı 5-15 dakikaya çekmek, ama bu UX’i mahveder
- Token blacklist: İptal edilen token’ları bir yerde tutmak
- Token versiyonlama: Kullanıcı bazlı token sürüm numarası
- Opaque token + introspection: Aslında JWT’yi yarı stateful yapmak
Biz bugün ağırlıklı olarak blacklist yaklaşımını ve token versiyonlamayı inceleyeceğiz.
Redis ile Blacklist Implementasyonu
Blacklist için Redis neredeyse standart seçim haline geldi. Sebebi basit: hızlı, TTL desteği var ve memory-based. Token iptal zamanından expire süresine kadar olan farkı TTL olarak set edersen, Redis otomatik temizlik yapıyor.
Önce temel bir Redis bağlantı kurulumu:
# Redis kurulumu (Ubuntu/Debian)
sudo apt update
sudo apt install redis-server -y
sudo systemctl enable redis-server
sudo systemctl start redis-server
# Redis'in ayakta olduğunu test et
redis-cli ping
# PONG
# Memory politikasını ayarla (blacklist için önemli)
redis-cli config set maxmemory-policy allkeys-lru
redis-cli config set maxmemory 256mb
Şimdi Node.js ile pratik bir blacklist servisi yazalım:
# Proje kurulumu
mkdir jwt-blacklist-demo && cd jwt-blacklist-demo
npm init -y
npm install express jsonwebtoken ioredis dotenv
Blacklist servisinin core mantığı:
// blacklist.service.js
const Redis = require('ioredis');
const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD,
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
}
});
class BlacklistService {
// Token'ı blacklist'e ekle
async addToBlacklist(token, expiresIn) {
const key = `blacklist:${token}`;
// Token'ın kalan geçerlilik süresini hesapla
const ttl = Math.max(expiresIn - Math.floor(Date.now() / 1000), 0);
if (ttl > 0) {
await redis.setex(key, ttl, '1');
console.log(`Token blacklisted for ${ttl} seconds`);
}
}
// Token blacklist'te mi kontrol et
async isBlacklisted(token) {
const key = `blacklist:${token}`;
const result = await redis.exists(key);
return result === 1;
}
// Kullanıcının tüm token'larını iptal et (şifre değişikliği senaryosu)
async revokeAllUserTokens(userId) {
const key = `user:revoked:${userId}`;
await redis.set(key, Date.now().toString());
// 30 gün sonra otomatik temizle
await redis.expire(key, 30 * 24 * 60 * 60);
}
// Kullanıcı için revoke timestamp'ini getir
async getUserRevokeTimestamp(userId) {
const key = `user:revoked:${userId}`;
const timestamp = await redis.get(key);
return timestamp ? parseInt(timestamp) : null;
}
}
module.exports = new BlacklistService();
Middleware Entegrasyonu
Blacklist kontrolünü her request’te yapan bir middleware:
// auth.middleware.js
const jwt = require('jsonwebtoken');
const blacklistService = require('./blacklist.service');
const authenticateToken = async (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Token bulunamadı' });
}
try {
// Önce blacklist kontrolü (JWT verify'dan önce)
const isBlacklisted = await blacklistService.isBlacklisted(token);
if (isBlacklisted) {
return res.status(401).json({ error: 'Token iptal edilmiş' });
}
// Token'ı verify et
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Kullanıcı seviyesinde revoke kontrolü
const revokeTimestamp = await blacklistService.getUserRevokeTimestamp(
decoded.userId
);
if (revokeTimestamp && decoded.iat * 1000 < revokeTimestamp) {
return res.status(401).json({
error: 'Oturum geçersiz, lütfen tekrar giriş yapın'
});
}
req.user = decoded;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token süresi dolmuş' });
}
return res.status(403).json({ error: 'Geçersiz token' });
}
};
module.exports = { authenticateToken };
Logout ve Şifre Değişikliği Senaryoları
Gerçek uygulamada iki temel iptal senaryosu var. Birincisi kullanıcının kendi isteğiyle logout etmesi, ikincisi admin müdahalesi veya şifre değişikliği.
// auth.routes.js
const express = require('express');
const jwt = require('jsonwebtoken');
const blacklistService = require('./blacklist.service');
const { authenticateToken } = require('./auth.middleware');
const router = express.Router();
// Login
router.post('/login', async (req, res) => {
const { username, password } = req.body;
// Kullanıcı doğrulama (basitleştirilmiş)
const user = await validateUser(username, password);
if (!user) {
return res.status(401).json({ error: 'Geçersiz kimlik bilgileri' });
}
const accessToken = jwt.sign(
{ userId: user.id, username: user.username, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // Kısa süre önemli!
);
const refreshToken = jwt.sign(
{ userId: user.id, type: 'refresh' },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Refresh token'ı DB'ye kaydet
await saveRefreshToken(user.id, refreshToken);
res.json({ accessToken, refreshToken });
});
// Logout - sadece mevcut token'ı iptal et
router.post('/logout', authenticateToken, async (req, res) => {
const token = req.headers['authorization'].split(' ')[1];
const decoded = req.user;
// Token'ı blacklist'e ekle
await blacklistService.addToBlacklist(token, decoded.exp);
// Refresh token'ı da sil
if (req.body.refreshToken) {
await deleteRefreshToken(req.body.refreshToken);
}
res.json({ message: 'Başarıyla çıkış yapıldı' });
});
// Tüm cihazlardan çıkış
router.post('/logout-all', authenticateToken, async (req, res) => {
const userId = req.user.userId;
// Tüm refresh token'ları sil
await deleteAllRefreshTokens(userId);
// Kullanıcı için global revoke timestamp set et
await blacklistService.revokeAllUserTokens(userId);
res.json({ message: 'Tüm cihazlardan çıkış yapıldı' });
});
// Şifre değişikliği
router.post('/change-password', authenticateToken, async (req, res) => {
const userId = req.user.userId;
const { oldPassword, newPassword } = req.body;
// Şifre değiştirme işlemi
const success = await changePassword(userId, oldPassword, newPassword);
if (!success) {
return res.status(400).json({ error: 'Mevcut şifre yanlış' });
}
// Mevcut token dahil TÜM tokenları iptal et
const currentToken = req.headers['authorization'].split(' ')[1];
await blacklistService.addToBlacklist(currentToken, req.user.exp);
await blacklistService.revokeAllUserTokens(userId);
await deleteAllRefreshTokens(userId);
res.json({ message: 'Şifre değiştirildi, lütfen tekrar giriş yapın' });
});
module.exports = router;
Admin Paneli ile Token Yönetimi
Kurumsal ortamlarda admin’in kullanıcı token’larını merkezi olarak yönetebilmesi gerekiyor. Hesap şüpheli aktivite gösteriyor, çalışan işten ayrılıyor gibi durumlarda anında müdahale şart:
// admin.routes.js - Admin token yönetim endpoint'leri
router.post('/admin/revoke-user/:userId',
authenticateToken,
requireRole('admin'),
async (req, res) => {
const { userId } = req.params;
const { reason } = req.body;
// Kullanıcının tüm token'larını iptal et
await blacklistService.revokeAllUserTokens(userId);
await deleteAllRefreshTokens(userId);
// Audit log tut
await auditLog.write({
action: 'TOKEN_REVOKED',
targetUserId: userId,
performedBy: req.user.userId,
reason: reason,
timestamp: new Date().toISOString()
});
// Aktif WebSocket bağlantılarını da kes (varsa)
await notifyUserDisconnect(userId);
res.json({
message: `Kullanıcı ${userId} için tüm token'lar iptal edildi`,
revokedAt: new Date().toISOString()
});
}
);
// Belirli bir token'ın durumunu sorgula
router.get('/admin/token-status',
authenticateToken,
requireRole('admin'),
async (req, res) => {
const { token } = req.query;
try {
const decoded = jwt.decode(token);
const isBlacklisted = await blacklistService.isBlacklisted(token);
const revokeTimestamp = await blacklistService.getUserRevokeTimestamp(
decoded.userId
);
const isGloballyRevoked = revokeTimestamp &&
decoded.iat * 1000 < revokeTimestamp;
res.json({
userId: decoded.userId,
issuedAt: new Date(decoded.iat * 1000).toISOString(),
expiresAt: new Date(decoded.exp * 1000).toISOString(),
isBlacklisted,
isGloballyRevoked,
isExpired: decoded.exp < Date.now() / 1000
});
} catch (error) {
res.status(400).json({ error: 'Geçersiz token formatı' });
}
}
);
Token Versiyonlama ile Alternatif Yaklaşım
Blacklist token’ın hash’ini saklar, bu da büyük sistemlerde ciddi memory kullanımına yol açabilir. Token versiyonlama daha elegant bir çözüm: kullanıcı tablosunda bir token_version kolonu tutuyorsun, her kritik olayda bu sayıyı artırıyorsun.
# PostgreSQL migration
psql -U postgres -d yourdb << 'EOF'
ALTER TABLE users ADD COLUMN IF NOT EXISTS token_version INTEGER DEFAULT 1;
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_revoke_at TIMESTAMP;
CREATE INDEX idx_users_token_version ON users(id, token_version);
EOF
Token üretirken ve doğrularken versiyon bilgisini kullan:
// Token versiyonlama implementasyonu
const generateTokenWithVersion = async (userId) => {
// Mevcut token versiyonunu DB'den al
const { rows } = await db.query(
'SELECT token_version FROM users WHERE id = $1',
[userId]
);
const tokenVersion = rows[0].token_version;
const accessToken = jwt.sign(
{
userId,
tokenVersion, // Versiyon token'a gömülü
},
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
return accessToken;
};
// Doğrulama middleware'inde versiyon kontrolü
const verifyWithVersion = async (decoded) => {
const { rows } = await db.query(
'SELECT token_version FROM users WHERE id = $1',
[decoded.userId]
);
if (rows[0].token_version !== decoded.tokenVersion) {
throw new Error('Token versiyonu geçersiz');
}
return true;
};
// Tüm token'ları iptal etmek için sadece versiyonu artır
const revokeAllTokens = async (userId) => {
await db.query(
`UPDATE users
SET token_version = token_version + 1,
last_revoke_at = NOW()
WHERE id = $1`,
[userId]
);
};
Bu yaklaşımın güzel tarafı: Redis’e ihtiyacın yok, DB’de tek bir integer kolonu yeterli. Dezavantajı ise her token doğrulamasında DB sorgusu atman gerekiyor, bu da yük altında performans sorununa dönüşebilir. Caching katmanı ekleyerek bunu çözebilirsin.
Redis Blacklist’i İzleme ve Boyut Yönetimi
Production’da blacklist’in boyutunu izlemen şart. Kontrolsüz büyürse Redis memory’yi patlatır:
#!/bin/bash
# blacklist-monitor.sh - Cron ile her 5 dakikada çalıştır
REDIS_HOST="${REDIS_HOST:-localhost}"
REDIS_PORT="${REDIS_PORT:-6379}"
ALERT_THRESHOLD=100000 # 100k key'den fazlaysa uyar
# Blacklist key sayısını al
BLACKLIST_COUNT=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT
SCAN 0 MATCH "blacklist:*" COUNT 1000 | grep -v "^[0-9]*$" | wc -l)
# Redis memory kullanımını al
REDIS_MEMORY=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT INFO memory |
grep "used_memory_human" | cut -d: -f2 | tr -d '[:space:]')
echo "$(date): Blacklist key sayisi: $BLACKLIST_COUNT, Memory: $REDIS_MEMORY"
# Eşik aşıldıysa Slack'e bildir
if [ "$BLACKLIST_COUNT" -gt "$ALERT_THRESHOLD" ]; then
curl -s -X POST "$SLACK_WEBHOOK_URL"
-H 'Content-type: application/json'
--data "{
"text": "JWT Blacklist uyarisi!",
"attachments": [{
"color": "danger",
"text": "Blacklist key sayisi $BLACKLIST_COUNT esigi asti. Redis memory: $REDIS_MEMORY"
}]
}"
fi
# Log dosyasına kaydet
echo "$(date),blacklist_count=$BLACKLIST_COUNT,redis_memory=$REDIS_MEMORY"
>> /var/log/jwt-blacklist-metrics.csv
# Cron ayarı
echo "*/5 * * * * /usr/local/bin/blacklist-monitor.sh >> /var/log/blacklist-monitor.log 2>&1" | crontab -
Blacklist büyüklüğünü minimize etmek için kısa access token süresi kritik. 15 dakikalık token için Redis’te en fazla 15 dakika saklarsın. 24 saatlik token’larda ise bu oran çarpıcı biçimde artıyor.
Performans Optimizasyonu: Bloom Filter Kullanımı
Yüksek trafikli sistemlerde her request için Redis’e gidip blacklist kontrolü yapmak ciddi latency ekleyebilir. Redis Bloom Filter ile bu sorunu çözebilirsin:
# RedisBloom modülü ile (Redis Stack veya RedisBloom gerekli)
# Docker ile test ortamı
docker run -p 6379:6379 redis/redis-stack-server:latest
# Bloom filter oluştur
# Kapasite: 1 milyon token, hata oranı: %1
redis-cli BF.RESERVE jwt_blacklist 0.01 1000000
// bloom-blacklist.service.js
class BloomBlacklistService {
async addToBlacklist(tokenId, ttl) {
// Hem Bloom filter'a hem klasik set'e ekle
await redis.call('BF.ADD', 'jwt_blacklist', tokenId);
// Kesin kontrol için normal key de tut
await redis.setex(`bl:${tokenId}`, ttl, '1');
}
async isBlacklisted(tokenId) {
// Önce Bloom filter'a bak (hızlı, ama false positive mümkün)
const mightExist = await redis.call('BF.EXISTS', 'jwt_blacklist', tokenId);
if (!mightExist) {
return false; // Kesinlikle blacklist'te değil
}
// Bloom filter "var" dedi, kesin kontrolü yap
const definitelyExists = await redis.exists(`bl:${tokenId}`);
return definitelyExists === 1;
}
}
Bloom filter’ın güzelliği: “yok” cevabı kesin doğru, “var” cevabı bazen yanlış olabilir. Yanlış pozitif oranı düşük tutulursa, çoğu request için ikinci Redis çağrısını atlarsın.
Güvenlik Açıkları ve Dikkat Edilmesi Gerekenler
Blacklist implementasyonunda sıkça yapılan hatalar:
- Token hash’i yerine JWT’nin tamamını saklamak: JWT’ler büyük olabilir, bunun yerine
jti(JWT ID) claim’ini kullan ve sadece onu sakla. Token oluştururken mutlakajtiekle:
const { v4: uuidv4 } = require('uuid');
const token = jwt.sign(
{
userId: user.id,
jti: uuidv4() // Her token için unique ID
},
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
// Blacklist'te JTI sakla, tokenın kendisini değil
await blacklistService.addToBlacklist(decoded.jti, decoded.exp);
- Redis’in down olması durumunda ne yapacaksın? Blacklist kontrol edemiyorsan token’ı geçerli mi sayacaksın geçersiz mi? Production’da fail-closed prensibi daha güvenli, ama bu erişimi tamamen keser. Sistemin kritiklik seviyesine göre karar ver ve bu senaryoyu README’ye yaz.
- Distributed sistemlerde Redis cluster kullanmak: Tek Redis instance’ı SPOF (Single Point of Failure) oluşturur. Redis Sentinel veya Redis Cluster kur.
- Blacklist zamanlaması: Token’ı blacklist’e eklerken mevcut zamanı değil, token’ın kendi
expclaim’ini baz al. Saat farkı (clock skew) sorunlarına karşı küçük bir buffer ekle:
const safeTTL = (decoded.exp - Math.floor(Date.now() / 1000)) + 30; // 30 sn buffer
Sonuç
JWT blacklist yönetimi “basit bir özellik” gibi görünse de production’da birçok edge case barındırıyor. Benim önerim şu sırayla ilerlemeniz:
- Küçük uygulamalar için: Token versiyonlama yeterli. DB’ye bir kolon ekle, her şifre değişikliğinde versiyon artır. Redis bile gerekmez.
- Orta ölçekli uygulamalar için: Redis blacklist +
jticlaim kombinasyonu. Access token süresini 15 dakikanın altında tut, bu hem güvenliği artırır hem de blacklist boyutunu küçük tutar.
- Yüksek trafikli sistemler için: Bloom filter + Redis blacklist + token versiyonlama üçlüsü. Ayrıca Redis Cluster zorunlu, monitoring şart.
Asıl mesaj şu: JWT’yi stateless kullanıyorum diye tokenları unutamazsın. Güvenlik olayları, hesap ele geçirme, çalışan ayrılışı gibi durumlar anında müdahale gerektiriyor. “Token zaten kısa süreli” demek yeterli değil, 15 dakika bile ciddi hasar için fazlasıyla yeterli bir pencere.
Redis kurulumundan blacklist servisine, admin panelinden monitoring scriptine kadar anlattıklarım production’a doğrudan taşınabilir yapılar. Bloom filter kısmını ise ancak gerçekten trafiğin Redis latency’sini darboğaz haline getirdiğini ölçümlediğinde devreye al, premature optimization’dan kaçın.
