JWT ile Kimlik Doğrulama Sistemi Kurulumu

Modern web uygulamalarında kimlik doğrulama, güvenliğin temel taşlarından biri. Eskiden session tabanlı sistemler her şeyi hallederdi ama mikroservis mimarileri ve stateless API’lar yaygınlaşınca JWT (JSON Web Token) vazgeçilmez bir çözüm haline geldi. Bu yazıda sıfırdan bir JWT kimlik doğrulama sistemi kuracağız, güvenlik tuzaklarını ele alacağız ve production ortamında dikkat etmeniz gereken her şeyi pratiğe dökeceğiz.

JWT Nedir ve Neden Kullanıyoruz?

JWT, iki taraf arasında güvenli bilgi aktarımı için kullanılan açık bir standarttır (RFC 7519). Üç bölümden oluşur: Header, Payload ve Signature. Bu üç bölüm Base64URL ile encode edilerek nokta ile birleştirilir.

Bir JWT token şöyle görünür:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwibmFtZSI6IkFobWV0IFlpbG1heiIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDAzNjAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Session tabanlı sistemlerde sunucu her istek için veritabanına danışmak zorunda kalır. JWT’de ise token içindeki bilgiler kriptografik olarak imzalandığı için sunucu sadece imzayı doğrular, veritabanına gitmeye gerek kalmaz. Bu sayede hem performans kazanırsınız hem de yatay ölçekleme çok kolaylaşır.

Gereksinimler ve Ortam Hazırlığı

Bu yazıda Node.js tabanlı bir örnek üzerinden gideceğiz ama prensipler her dil ve framework için geçerli. Sistemi Ubuntu 22.04 üzerinde kuracağız.

# Node.js ve npm kurulumu (NVM ile)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
source ~/.bashrc
nvm install 20
nvm use 20

# Proje dizini oluştur
mkdir jwt-auth-system && cd jwt-auth-system
npm init -y

# Gerekli paketleri yükle
npm install express jsonwebtoken bcryptjs dotenv express-rate-limit helmet cors
npm install --save-dev nodemon jest supertest

Paketlerin ne işe yaradığını kısaca açıklayalım:

  • jsonwebtoken: JWT oluşturma ve doğrulama için
  • bcryptjs: Parola hashleme için
  • dotenv: Ortam değişkenlerini yönetmek için
  • express-rate-limit: Brute force saldırılarına karşı rate limiting
  • helmet: HTTP güvenlik başlıklarını otomatik ayarlar

Ortam Değişkenleri ve Yapılandırma

Güvenlik açısından en kritik konu, secret key yönetimi. Secret key’i kod içine gömmek ciddi bir güvenlik açığıdır. Her zaman ortam değişkeni kullanın.

# .env dosyası oluştur
cat > .env << 'EOF'
# JWT Ayarları
JWT_ACCESS_SECRET=bu-cok-uzun-ve-karmasik-bir-secret-key-olmali-minimum-256-bit
JWT_REFRESH_SECRET=refresh-icin-ayri-bir-secret-key-kullanmak-zorunludur
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d

# Sunucu Ayarları
PORT=3000
NODE_ENV=production

# Veritabanı (örnek olarak)
DB_HOST=localhost
DB_PORT=5432
DB_NAME=authdb
DB_USER=authuser
DB_PASS=guclu-veritabani-sifresi
EOF

# .env dosyasını git'e ekleme
echo ".env" >> .gitignore
echo "node_modules/" >> .gitignore

# Production için güçlü secret key üret
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

.env dosyasını asla versiyon kontrolüne eklemeyin. Production sunucularında ortam değişkenlerini sistemin kendisinden veya bir secret manager (HashiCorp Vault, AWS Secrets Manager) üzerinden yönetin.

Temel JWT İşlemleri

Önce JWT’nin çalışma mantığını anlatan basit bir utility modülü oluşturalım:

// utils/jwt.js
const jwt = require('jsonwebtoken');

const generateAccessToken = (payload) => {
  return jwt.sign(
    {
      sub: payload.userId,
      email: payload.email,
      role: payload.role,
      iat: Math.floor(Date.now() / 1000)
    },
    process.env.JWT_ACCESS_SECRET,
    {
      expiresIn: process.env.JWT_ACCESS_EXPIRY || '15m',
      algorithm: 'HS256',
      issuer: 'myapp.com',
      audience: 'myapp-users'
    }
  );
};

const generateRefreshToken = (payload) => {
  return jwt.sign(
    {
      sub: payload.userId,
      tokenVersion: payload.tokenVersion || 0
    },
    process.env.JWT_REFRESH_SECRET,
    {
      expiresIn: process.env.JWT_REFRESH_EXPIRY || '7d',
      algorithm: 'HS256'
    }
  );
};

const verifyAccessToken = (token) => {
  try {
    return jwt.verify(token, process.env.JWT_ACCESS_SECRET, {
      algorithms: ['HS256'],
      issuer: 'myapp.com',
      audience: 'myapp-users'
    });
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      throw new Error('TOKEN_EXPIRED');
    }
    if (error.name === 'JsonWebTokenError') {
      throw new Error('TOKEN_INVALID');
    }
    throw error;
  }
};

const verifyRefreshToken = (token) => {
  try {
    return jwt.verify(token, process.env.JWT_REFRESH_SECRET, {
      algorithms: ['HS256']
    });
  } catch (error) {
    throw new Error('REFRESH_TOKEN_INVALID');
  }
};

module.exports = {
  generateAccessToken,
  generateRefreshToken,
  verifyAccessToken,
  verifyRefreshToken
};

Burada dikkat edilmesi gereken birkaç önemli nokta var. algorithm parametresini her zaman açıkça belirtin. Eğer belirtmezseniz bazı eski kütüphane sürümlerinde “alg: none” saldırısına açık hale gelebilirsiniz. Ayrıca issuer ve audience alanları token’ın hangi uygulama için verildiğini belirtir ve token çalma saldırılarını zorlaştırır.

Authentication Middleware

Express uygulamasında route’ları korumak için bir middleware yazalım:

// middleware/auth.js
const { verifyAccessToken } = require('../utils/jwt');

const authenticateToken = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  
  // Bearer token formatını kontrol et
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({
      success: false,
      error: 'Authorization header eksik veya hatalı format'
    });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = verifyAccessToken(token);
    req.user = {
      userId: decoded.sub,
      email: decoded.email,
      role: decoded.role
    };
    next();
  } catch (error) {
    if (error.message === 'TOKEN_EXPIRED') {
      return res.status(401).json({
        success: false,
        error: 'Token süresi dolmuş',
        code: 'TOKEN_EXPIRED'
      });
    }
    return res.status(403).json({
      success: false,
      error: 'Geçersiz token'
    });
  }
};

// Rol tabanlı yetkilendirme
const requireRole = (...roles) => {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ success: false, error: 'Kimlik doğrulama gerekli' });
    }
    
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        success: false,
        error: `Bu işlem için ${roles.join(' veya ')} rolü gerekli`
      });
    }
    
    next();
  };
};

module.exports = { authenticateToken, requireRole };

Login ve Token Yenileme Endpoint’leri

Gerçek bir uygulamada login akışı şöyle işler:

// routes/auth.js
const express = require('express');
const bcrypt = require('bcryptjs');
const rateLimit = require('express-rate-limit');
const {
  generateAccessToken,
  generateRefreshToken,
  verifyRefreshToken
} = require('../utils/jwt');

const router = express.Router();

// Login için özel rate limiting - brute force koruması
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 dakika
  max: 5, // Maksimum 5 deneme
  message: {
    success: false,
    error: 'Çok fazla başarısız giriş denemesi. 15 dakika sonra tekrar deneyin.'
  },
  standardHeaders: true,
  legacyHeaders: false
});

// Örnek kullanıcı veritabanı (gerçekte PostgreSQL/MongoDB kullanılır)
// Token version, tüm token'ları geçersiz kılmak için kullanılır
const users = new Map([
  ['[email protected]', {
    id: 'user-123',
    email: '[email protected]',
    passwordHash: '$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewdBPj4J/HSiHAq6',
    role: 'admin',
    tokenVersion: 0
  }]
]);

// Refresh token'ları saklamak için (gerçekte Redis kullanın)
const refreshTokenStore = new Set();

// POST /auth/login
router.post('/login', loginLimiter, async (req, res) => {
  const { email, password } = req.body;

  if (!email || !password) {
    return res.status(400).json({
      success: false,
      error: 'Email ve parola zorunludur'
    });
  }

  const user = users.get(email.toLowerCase());
  
  // Kullanıcı bulunamasa bile aynı süre bekle (timing attack önlemi)
  const isValidPassword = user
    ? await bcrypt.compare(password, user.passwordHash)
    : await bcrypt.compare(password, '$2a$12$dummy.hash.for.timing.protection');

  if (!user || !isValidPassword) {
    return res.status(401).json({
      success: false,
      error: 'Geçersiz email veya parola'
    });
  }

  const accessToken = generateAccessToken({
    userId: user.id,
    email: user.email,
    role: user.role
  });

  const refreshToken = generateRefreshToken({
    userId: user.id,
    tokenVersion: user.tokenVersion
  });

  // Refresh token'ı kaydet
  refreshTokenStore.add(refreshToken);

  // Refresh token'ı HttpOnly cookie olarak gönder
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000 // 7 gün
  });

  res.json({
    success: true,
    accessToken,
    expiresIn: 900, // 15 dakika (saniye cinsinden)
    user: {
      id: user.id,
      email: user.email,
      role: user.role
    }
  });
});

// POST /auth/refresh
router.post('/refresh', async (req, res) => {
  const refreshToken = req.cookies?.refreshToken;

  if (!refreshToken || !refreshTokenStore.has(refreshToken)) {
    return res.status(401).json({
      success: false,
      error: 'Geçersiz veya süresi dolmuş refresh token'
    });
  }

  try {
    const decoded = verifyRefreshToken(refreshToken);
    const user = [...users.values()].find(u => u.id === decoded.sub);

    if (!user || user.tokenVersion !== decoded.tokenVersion) {
      refreshTokenStore.delete(refreshToken);
      return res.status(401).json({
        success: false,
        error: 'Token geçersiz kılınmış'
      });
    }

    // Eski refresh token'ı sil (token rotation)
    refreshTokenStore.delete(refreshToken);

    const newAccessToken = generateAccessToken({
      userId: user.id,
      email: user.email,
      role: user.role
    });

    const newRefreshToken = generateRefreshToken({
      userId: user.id,
      tokenVersion: user.tokenVersion
    });

    refreshTokenStore.add(newRefreshToken);

    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000
    });

    res.json({
      success: true,
      accessToken: newAccessToken,
      expiresIn: 900
    });
  } catch (error) {
    refreshTokenStore.delete(refreshToken);
    res.clearCookie('refreshToken');
    res.status(401).json({
      success: false,
      error: 'Refresh token doğrulanamadı'
    });
  }
});

// POST /auth/logout
router.post('/logout', (req, res) => {
  const refreshToken = req.cookies?.refreshToken;
  if (refreshToken) {
    refreshTokenStore.delete(refreshToken);
  }
  res.clearCookie('refreshToken');
  res.json({ success: true, message: 'Başarıyla çıkış yapıldı' });
});

module.exports = router;

Token Blacklisting ve Logout Stratejisi

JWT’nin stateless yapısı büyük avantaj sağlar ama logout işlemini karmaşıklaştırır. Kullanıcı logout yaptığında access token’ın süresi dolana kadar teorik olarak hala geçerlidir. Bunun için Redis tabanlı bir blacklist mekanizması kullanabilirsiniz:

# Redis kurulumu
sudo apt update
sudo apt install -y redis-server

# Redis'i başlat ve servis olarak etkinleştir
sudo systemctl start redis-server
sudo systemctl enable redis-server

# Redis bağlantısını test et
redis-cli ping
# PONG çıktısı almalısınız

# Node.js için Redis client kur
npm install ioredis
// utils/tokenBlacklist.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_PASS,
  retryStrategy: (times) => {
    const delay = Math.min(times * 50, 2000);
    return delay;
  }
});

// Token'ı blacklist'e ekle (süre dolana kadar)
const blacklistToken = async (token, expiresIn) => {
  const key = `blacklist:${token}`;
  await redis.setex(key, expiresIn, '1');
};

// Token blacklist'te mi kontrol et
const isTokenBlacklisted = async (token) => {
  const result = await redis.get(`blacklist:${token}`);
  return result !== null;
};

module.exports = { blacklistToken, isTokenBlacklisted };

Bu yaklaşımda token’ı blacklist’e eklerken TTL değerini token’ın kalan geçerlilik süresi olarak ayarlayın. Bu sayede Redis gereksiz veriyle dolmaz ve token süresi dolunca otomatik temizlenir.

Güvenlik En İyi Pratikleri

JWT sistemlerinde sıkça yapılan hataları ve bunlardan nasıl kaçınacağınızı ele alalım:

HTTPS Zorunluluğu

Production’da JWT’yi asla HTTP üzerinden göndermeyin. Nginx ile SSL termination:

# Nginx konfigürasyonu - /etc/nginx/sites-available/jwt-api
server {
    listen 80;
    server_name api.orneksite.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name api.orneksite.com;

    ssl_certificate /etc/letsencrypt/live/api.orneksite.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.orneksite.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    # Security headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        
        # Token boyutu sınırı - büyük token saldırılarını önler
        proxy_buffer_size 8k;
    }
}

Secret Key Rotation

Production sistemlerde periyodik olarak secret key değiştirmeniz gerekebilir. Ani geçiş yerine kademeli geçiş için birden fazla key destekleyebilirsiniz:

# Crontab ile otomatik key rotation hatırlatıcısı
# Her 90 günde bir yöneticiye mail gönder
0 9 1 */3 * /usr/local/bin/jwt-key-rotation-reminder.sh

# Mevcut key fingerprint'ini log'a kaydet
openssl dgst -sha256 <(echo -n "$JWT_ACCESS_SECRET") | awk '{print $2}'

Payload Boyutu

JWT payload’ına gereğinden fazla veri koymayın. Her request’te bu veri taşınır. Sadece kimlik doğrulama için gereken minimum bilgiyi saklayın: kullanıcı ID’si, rol ve token metadata’sı yeterlidir. Kullanıcının tam profil bilgisini, email listelerini veya kapsamlı izin tablolarını payload’a koymak hem performans sorununa hem de bilgi sızdırma riskine yol açar.

Token Yenileme Stratejisi – Frontend Perspektifi

Gerçek dünyada frontend tarafında access token süresi dolduğunda otomatik yenileme yapmanız gerekir. Bu işlemin sysadmin açısından önemi, sistemin doğru log’laması ve monitoring’idir:

# JWT ile ilgili hataları izlemek için log parsing
# /var/log/nginx/access.log üzerinden 401 hatalarını takip et
tail -f /var/log/nginx/access.log | grep " 401 "

# Token expired hatalarını say (dakika başına)
journalctl -u myapp --since "1 hour ago" | 
  grep "TOKEN_EXPIRED" | 
  awk '{print $1, $2}' | 
  cut -d: -f1,2 | 
  sort | uniq -c | 
  sort -rn

# Şüpheli çok sayıda token hatası üretip üretmediğini kontrol et
# (olası token çalma girişimi)
grep "TOKEN_INVALID" /var/log/myapp/app.log | 
  awk '{print $NF}' | 
  sort | uniq -c | 
  sort -rn | head -20

Monitoring ve Alerting

Production’da JWT sistemini izlemek için temel metrikler:

# Prometheus metrikleri için basit bir script
# /usr/local/bin/jwt-metrics.sh

#!/bin/bash
APP_LOG="/var/log/myapp/app.log"
METRICS_FILE="/var/lib/prometheus/node-exporter/jwt_metrics.prom"

# Son 5 dakikadaki başarılı login sayısı
LOGIN_SUCCESS=$(grep "LOGIN_SUCCESS" "$APP_LOG" | 
  awk -v d="$(date -d '5 minutes ago' '+%Y-%m-%d %H:%M')" '$0 >= d' | wc -l)

# Başarısız login sayısı
LOGIN_FAIL=$(grep "LOGIN_FAILED" "$APP_LOG" | 
  awk -v d="$(date -d '5 minutes ago' '+%Y-%m-%d %H:%M')" '$0 >= d' | wc -l)

# Geçersiz token denemeleri
INVALID_TOKEN=$(grep "TOKEN_INVALID" "$APP_LOG" | 
  awk -v d="$(date -d '5 minutes ago' '+%Y-%m-%d %H:%M')" '$0 >= d' | wc -l)

cat > "$METRICS_FILE" << EOF
# HELP jwt_login_success_total Başarılı login sayısı
# TYPE jwt_login_success_total counter
jwt_login_success_total $LOGIN_SUCCESS

# HELP jwt_login_failed_total Başarısız login sayısı
# TYPE jwt_login_failed_total counter
jwt_login_failed_total $LOGIN_FAIL

# HELP jwt_invalid_token_total Geçersiz token denemesi
# TYPE jwt_invalid_token_total counter
jwt_invalid_token_total $INVALID_TOKEN
EOF

Bu scripti 5 dakikada bir cron ile çalıştırarak Prometheus + Grafana üzerinden görselleştirebilirsiniz. Özellikle jwt_invalid_token_total metriği kısa sürede ani artış gösteriyorsa, bu bir saldırı girişiminin işareti olabilir.

Yaygın Hatalar ve Çözümleri

Prodüksiyonda karşılaştığım en yaygın JWT sorunlarını ve çözümlerini paylaşayım:

  • Saat senkronizasyonu sorunu: JWT, zaman tabanlı doğrulama yapar. Sunucu saatiniz yanlışsa token’lar erken expire olabilir ya da henüz geçerli olmayan token’lar kabul edilebilir. ntpd veya chrony ile saati senkronize edin.
  • Secret key’i kod içine gömmek: Özellikle geliştirme ortamında başlayıp production’a taşınan kodlarda sıkça görülür. Her zaman ortam değişkeni kullanın.
  • Access ve refresh token için aynı secret kullanmak: İki token için mutlaka farklı secret’lar kullanın. Birinin sızdırılması diğerini etkilememelidir.
  • Token payload’ını şifreli sanmak: JWT imzalıdır ama şifreli değildir. Payload Base64 decode edilerek okunabilir. Hassas bilgileri payload’a koymayın.
  • Refresh token rotation yapmamak: Refresh token çalındığında saldırgan süresiz erişim elde eder. Her kullanımda yeni refresh token üretip eskisini geçersiz kılın.
  • CORS ayarlarını yanlış yapmak: API’yi farklı domain’den kullanan frontend’lerde credentials: ‘include’ ile birlikte CORS ayarlarının doğru yapılmaması token’ların gönderilememesine yol açar.

Sonuç

JWT kimlik doğrulama sistemi kurmak teknik olarak göründüğü kadar karmaşık değil ama güvenli bir şekilde yönetmek ciddi dikkat gerektiriyor. Bu yazıda anlattıklarımı özetlemek gerekirse: access token’ı kısa tutun (15 dakika ideal), refresh token’ı HttpOnly cookie’de saklayın, mutlaka token rotation yapın ve Redis ile blacklist mekanizması kurun. Production’da HTTPS zorunlu, secret key’ler ortam değişkenlerinde ve monitoring aktif olmalı.

Mikroservis mimarisine geçiyorsanız veya birden fazla uygulama arasında kimlik doğrulama paylaşmanız gerekiyorsa JWT mükemmel bir çözüm. Ama tek bir sunuculu, session desteği olan geleneksel bir uygulama için session tabanlı kimlik doğrulama hala daha basit ve yönetilebilir olabilir. Teknoloji seçiminde her zaman kullanım senaryonuzu göz önünde bulundurun.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir