Mert Özen Her Satırda Daha İleri
Güvenlik Authentication ve Authorization — #09

Refresh Token'lar: Rotation, Reuse Detection, Revocation ve Sunucu Tarafında Güvenli Saklama

Mert Özen 8 Kas 2026 10 dk 4 görüntülenme
Refresh Token'lar: Rotation, Reuse Detection, Revocation ve Sunucu Tarafında Güvenli Saklama

Refresh token her kullanımda yenilenmeli ve tüketilmiş bir token tekrar gelirse o girişin bütün token family'si iptal edilmeli; RFC 9700 public client'lar için bunu ya da sender-constrained token'ı zorunlu tutuyor. EF Core ve SQLite ile SHA-256 hash'li saklamayı, ExecuteUpdate ile atomik tüketimi, reuse detection'ı, iki sekme yarışını, sliding ve absolute ömrü ve tüm cihazlardan çıkışı gerçek çıktılarla kuruyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10.0.13 runtime), Microsoft.EntityFrameworkCore.Sqlite 10.0.14, Microsoft.AspNetCore.Authentication.JwtBearer 10.0.14 ve Microsoft.IdentityModel.JsonWebTokens 8.23.0 ile derlenip denenmiştir.

Önce Tahmin Et

Ada'nın Angular uygulaması 5 dakikalık access token'ını refresh token ile yeniliyor. Bir gün refresh token'larından biri (R2) bir hata log'una düşüyor ve bir saldırganın eline geçiyor. Ada'nın uygulaması R2'yi zaten kullanmış ve yerine üçüncü token'ı (R3) almış. On saniye sonra saldırgan R2 ile refresh deniyor. Saldırgana ne olur, Ada'ya ne olur?

login        -> 200 refresh token 9vdyrvDL0s..., GET /orders -> 200
refresh R1   -> 200 new refresh token VkGJEUWFMf...
refresh R2   -> 200 new refresh token T1Dx_G_zfW...
refresh R2   -> 401 Reused
refresh R3   -> 401 Revoked

Saldırgan reddediliyor, ama Ada da dışarıda kalıyor: R3 artık geçersiz. Bu bir hata değil, tasarımın kendisi. Sunucu "aynı token iki kez geldi" bilgisinden kimin meşru olduğunu çıkaramaz; tek bildiği, token'ın bir kopyasının dolaşımda olduğu. O yüzden o girişten türeyen bütün token'ları (family) iptal ediyor ve Ada'yı yeniden giriş yapmaya zorluyor. 3. yazıda MapIdentityApi'nin refresh token'ının aynı token'la iki kez 200 döndüğünü görmüştük; bu yazıda o eksikliği kapatan sistemi kuruyoruz: hash'lenerek saklanan refresh token'lar, rotation, reuse detection, eşzamanlı istekler, ömür ve iptal.

Neden İki Token?

Bir önceki yazının sonunda JWT'nin (JSON Web Token) en büyük zayıflığını gördük: imzalı bir token süresi dolana kadar geçerlidir ve iptal edilemez. Çözüm sorumluluğu ikiye bölmek. Access token kısa ömürlüdür (Bookstore'da 5 dakika) ve API (Application Programming Interface)'ler onu veritabanına gitmeden doğrular. Refresh token uzun ömürlüdür, yalnız token endpoint'ine gider ve her kullanımında sunucudaki kayda bakılır. İptal noktası refresh token'dır: onu durdurursan en geç 5 dakika sonra kullanıcının erişimi biter.

RFC (Request for Comments) 9700, yani OAuth 2.0 güvenlik BCP'si (Best Current Practice), tarayıcı ve mobil uygulama gibi public client'lara verilen refresh token'lar için iki yoldan birini zorunlu tutuyor: sender-constrained token (DPoP (Demonstrating Proof of Possession) ya da mTLS (mutual Transport Layer Security) ile bir key'e bağlanmış token) veya refresh token rotation. Sender-constrained token'lar serinin 13. yazısında; burada rotation'ı kuruyoruz. Aynı bölüm, bir süre kullanılmayan refresh token'ların sona ermesini de öneriyor.

Şema: Hash'lenmiş Token ve Family

public sealed class RefreshToken
{
    public Guid Id { get; init; } = Guid.CreateVersion7();
    public Guid FamilyId { get; init; }
    public required string UserId { get; init; }
    public required string TokenHash { get; init; }
    public DateTimeOffset CreatedAt { get; init; }
    public DateTimeOffset ExpiresAt { get; init; }
    public DateTimeOffset FamilyExpiresAt { get; init; }
    public DateTimeOffset? ConsumedAt { get; set; }
    public Guid? ReplacedById { get; set; }
    public DateTimeOffset? RevokedAt { get; set; }
    public string? RevokedReason { get; set; }
}

Üç alan tasarımın kalbi. FamilyId, bir girişten türeyen bütün token'ları bağlar; login yeni bir family başlatır, her rotation aynı family'de yeni bir satır ekler. ConsumedAt, token'ın bir kez kullanıldığını kaydeder. TokenHash ise token'ın kendisi değil, Secure Hash Algorithm 256 (SHA-256) özetidir:

private (string Token, RefreshToken Entity) Create(string userId, Guid familyId, DateTimeOffset now, DateTimeOffset familyExpiresAt)
{
    // 256 bit rastgele değer; yalnız hash'i saklanır, token istemciye bir kez gösterilir
    string token = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(32));
    DateTimeOffset idleExpiry = now + _options.IdleLifetime;
    var entity = new RefreshToken
    {
        FamilyId = familyId,
        UserId = userId,
        TokenHash = Hash(token),
        CreatedAt = now,
        ExpiresAt = idleExpiry < familyExpiresAt ? idleExpiry : familyExpiresAt,
        FamilyExpiresAt = familyExpiresAt
    };
    return (token, entity);
}

private static string Hash(string token) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(token)));

Refresh token bir parola gibi saklanmalı: veritabanı yedeği sızarsa içindeki değerlerle oturum açılamamalı. Parolada yavaş bir hash (PBKDF2: Password-Based Key Derivation Function 2) ve salt gerekir, çünkü parolalar tahmin edilebilir. Burada değer 256 bit rastgele; tahmin edilemez, bu yüzden tek bir SHA-256 yeterli ve salt'sız olması aramayı tek bir unique index üzerinden yapmayı mümkün kılıyor. Oluşan tablo:

CREATE TABLE "RefreshTokens" (
    "Id" TEXT NOT NULL CONSTRAINT "PK_RefreshTokens" PRIMARY KEY,
    "FamilyId" TEXT NOT NULL,
    "UserId" TEXT NOT NULL,
    "TokenHash" TEXT NOT NULL,
    "CreatedAt" TEXT NOT NULL,
    "ExpiresAt" TEXT NOT NULL,
    "FamilyExpiresAt" TEXT NOT NULL,
    "ConsumedAt" TEXT NULL,
    "ReplacedById" TEXT NULL,
    "RevokedAt" TEXT NULL,
    "RevokedReason" TEXT NULL
);
CREATE INDEX "IX_RefreshTokens_FamilyId" ON "RefreshTokens" ("FamilyId");
CREATE UNIQUE INDEX "IX_RefreshTokens_TokenHash" ON "RefreshTokens" ("TokenHash");
CREATE INDEX "IX_RefreshTokens_UserId" ON "RefreshTokens" ("UserId");

Guid.CreateVersion7() (.NET 9 ile geldi) zaman sıralı id üretir; index'ler sona eklenerek büyür ve rastgele Guid'lerin yol açtığı sayfa bölünmeleri olmaz. Lab SQLite kullanıyor; şemanın Entity Framework Core (EF Core) tarafındaki mekaniği EF Core serisinde anlatıldı.

Rotation: Atomik Tüketim

public async Task<RefreshResult> RotateAsync(string presented)
{
    DateTimeOffset now = time.GetUtcNow();
    string hash = Hash(presented);
    RefreshToken? current = await db.RefreshTokens.AsNoTracking()
        .SingleOrDefaultAsync(t => t.TokenHash == hash);

    if (current is null)
    {
        return new(RefreshStatus.Unknown);
    }
    if (current.RevokedAt is not null)
    {
        return new(RefreshStatus.Revoked);
    }
    if (current.ConsumedAt is not null)
    {
        return await HandleReuseAsync(current, now);
    }
    if (current.ExpiresAt <= now)
    {
        return new(RefreshStatus.Expired);
    }

    // Yalnız başka biri önce davranmadıysa tüketildi olarak işaretle (atomik compare-and-set)
    int claimed = await db.RefreshTokens
        .Where(t => t.Id == current.Id && t.ConsumedAt == null && t.RevokedAt == null)
        .ExecuteUpdateAsync(s => s.SetProperty(t => t.ConsumedAt, now));
    if (claimed == 0)
    {
        return new(RefreshStatus.InProgress);
    }

    (string token, RefreshToken next) = Create(current.UserId, current.FamilyId, now, current.FamilyExpiresAt);
    db.RefreshTokens.Add(next);
    await db.SaveChangesAsync();
    await db.RefreshTokens.Where(t => t.Id == current.Id)
        .ExecuteUpdateAsync(s => s.SetProperty(t => t.ReplacedById, next.Id));
    return new(RefreshStatus.Rotated, current.UserId, token);
}

Kritik satır ExecuteUpdateAsync. Token'ı okuyup ConsumedAt'i C# tarafında set edip SaveChangesAsync çağırmak, aynı anda gelen iki isteğin ikisinin de "henüz tüketilmemiş" görmesine izin verir. WHERE ConsumedAt IS NULL koşullu tek bir UPDATE ise veritabanının kendi atomikliğini kullanır: iki istekten yalnız biri bir satır günceller, diğeri 0 alır. EF Core serisinin concurrency yazısındaki concurrency token'ın amacı da aynı; burada daha dar bir koşulla yapıyoruz. Bir de hata sırası önemli: önce iptal, sonra tüketim, en son süre kontrol ediliyor; iptal edilmiş bir family'nin token'ı "süresi dolmuş" değil "iptal" cevabı almalı ki loglar doğru hikâyeyi anlatsın.

Reuse Detection: Family'yi İptal Etmek

09-refresh-rotation-tr.png

private async Task<RefreshResult> HandleReuseAsync(RefreshToken token, DateTimeOffset now)
{
    // Aynı anda refresh yapan iki sekme hırsızlık değildir: kısa bir tolerans penceresi ver
    if (now - token.ConsumedAt!.Value <= _options.ReuseGrace)
    {
        return new(RefreshStatus.InProgress);
    }

    int revoked = await RevokeFamilyAsync(token.FamilyId, "reuse-detected", now);
    logger.LogWarning("Refresh token reuse detected for {UserId}; family {FamilyId} revoked ({Count} tokens)",
        token.UserId, token.FamilyId, revoked);
    return new(RefreshStatus.Reused);
}

private Task<int> RevokeFamilyAsync(Guid familyId, string reason, DateTimeOffset now) =>
    db.RefreshTokens.Where(t => t.FamilyId == familyId && t.RevokedAt == null)
        .ExecuteUpdateAsync(s => s.SetProperty(t => t.RevokedAt, now).SetProperty(t => t.RevokedReason, reason));

Tüketilmiş bir token'ın tekrar gelmesi iki şeyden biri demek: ya istemci aynı token'ı iki kez gönderdi ya da token'ın bir kopyası başka birinin elinde. Hangisinin hangisi olduğunu bilemeyiz, ama iki ihtimalde de family'deki aktif token da şüpheli. RFC 9700 bunu tarif ediyor: sunucu aktif refresh token'ı iptal eder ve meşru istemciyi yeni bir yetkilendirmeye zorlar. Baştaki senaryonun log'u ve tablonun son hâli:

warn: Bookstore.Auth.RefreshTokenService[0]
      Refresh token reuse detected for ada@bookstore.test; family 01a1b9d1-107e-798f-a784-cf06a1f84e80 revoked (3 tokens)

hash|family|UserId|consumed|replaced|RevokedReason
e14d653f56bf|CF06A1F84E80|ada@bookstore.test|1|1|reuse-detected
d5eb20f36f0c|CF06A1F84E80|ada@bookstore.test|1|1|reuse-detected
0c8af6015cb2|CF06A1F84E80|ada@bookstore.test|0|0|reuse-detected

Üç token aynı family'de: ikisi tüketilmiş ve yerlerine yenisi verilmiş, sonuncusu aktifken iptal edilmiş. ReplacedById zinciri, bir olay incelemesinde "bu token'dan sonra ne verildi?" sorusunu cevaplıyor. Bu warning bir güvenlik olayıdır; Web Güvenliği (OWASP Top 10) serisinin 15. yazısında anlatılan güvenlik loglaması ve alarm akışına bağlanmalı.

İki Sekme Yarışı

Reuse detection'ın yan etkisi, meşru kullanıcıyı yanlışlıkla dışarı atabilmesi. Ada iki sekme açmış; ikisi de aynı anda süresi dolan access token'ı fark edip aynı refresh token'la yenilemeye çalışıyor. Lab'da aynı token'la iki paralel istek attım, sonra kaybeden sekme 3 saniye sonra tekrar denedi:

== parallel refresh from two tabs
tab -> 409 refresh_in_progress
tab -> 200 rotated
refresh G1   -> 409 refresh_in_progress
refresh G2   -> 200 new refresh token pZUSCg_-oa...

Compare-and-set'i kaybeden istek 409 aldı, family iptal edilmedi. 3 saniye sonraki deneme de tolerans penceresinin (ReuseGrace, 10 saniye) içinde kaldığı için 409 aldı; kazanan sekmenin token'ı (G2) çalışmaya devam ediyor. Pencere bir güvenlik tavizi: bu 10 saniye içinde çalınan bir token'ın tekrar kullanımı reuse olarak işaretlenmiyor, ama saldırgan da yeni bir token alamıyor, yalnız 409 görüyor. Asıl çözüm istemcide: Angular'da eşzamanlı 401'leri tek bir refresh isteğinde toplamak (single-flight) ve sekmeler arasında BroadcastChannel ile paylaşmak. Bunu serinin 17. yazısında yazacağız; sunucudaki pencere, istemci her şeyi doğru yapamadığında kullanıcıyı korumak için.

Ömür: Sliding ve Absolute

public sealed class RefreshTokenOptions
{
    public TimeSpan IdleLifetime { get; set; } = TimeSpan.FromDays(3);
    public TimeSpan AbsoluteLifetime { get; set; } = TimeSpan.FromDays(14);
    public TimeSpan ReuseGrace { get; set; } = TimeSpan.FromSeconds(10);
}

Her yeni token, idle ömür kadar ileriye ama family'nin mutlak bitişinden öteye geçmeyecek bir ExpiresAt alıyor. Böylece 3 gün hiç kullanılmayan bir oturum ölüyor (RFC 9700'ün hareketsizlik önerisi), her gün kullanılan bir oturum da en fazla 14 gün yaşıyor. 2. yazıdaki cookie'nin sliding ve absolute ayrımının aynısı. Lab'ı 5 saniyelik idle ve 8 saniyelik absolute ömürle çalıştırınca:

t= 4.2s refresh -> 200 rotated
t= 7.2s refresh -> 200 rotated
t= 9.3s refresh -> 401 Expired
idle 6s       -> 401 Expired

Her refresh 5 saniyeden kısa aralıkla geldiği hâlde 9. saniyede oturum bitti: mutlak sınır 8 saniyeydi. Hiç kullanılmadan 6 saniye bekleyen ikinci oturum ise idle sınırından düştü.

Logout ve "Tüm Cihazlardan Çıkış"

Logout, istemcinin elindeki refresh token'ın family'sini iptal eder. "Tüm cihazlardan çıkış" kullanıcının bütün aktif family'lerini iptal eder:

auth.MapPost("/logout-all", async (ClaimsPrincipal user, RefreshTokenService refreshTokens) =>
{
    int families = await refreshTokens.RevokeAllForUserAsync(user.FindFirstValue("sub")!, "logout-all");
    return TypedResults.Ok(new { revokedSessions = families });
}).RequireAuthorization();
logout-all   -> 200 {'revokedSessions': 2}
refresh phone -> 401 Revoked

Telefonun elindeki access token en fazla 5 dakika daha çalışır; refresh edemediği an oturum biter. Bu gecikme kabul edilemiyorsa (örneğin bir çalışanın işten çıkarıldığı an) API'ler kısa süreli bir iptal listesine (jti ya da kullanıcı bazlı) bakabilir; bunun bedeli her istekte bir cache sorgusu. Parola değişikliği, parola sıfırlama ve 2FA (Two-Factor Authentication) ayarının değişmesi de RevokeAllForUserAsync'i çağırmalı; RFC 9700 bu tür güvenlik olaylarında otomatik iptali isteğe bağlı bir seçenek olarak anıyor, Bookstore'da varsayılan yapıyoruz.

Üretimde Dikkat

Tablo büyür. Her refresh bir satır ekliyor; 5 dakikalık access token'la aktif bir kullanıcı günde yüzlerce satır üretebilir. Süresi ve family ömrü dolmuş satırları bir background job ile sil, ama inceleme için iptal edilmiş family'leri bir süre sakla. TokenHash üzerindeki unique index aramayı, FamilyId ve UserId index'leri toplu iptali hızlı tutuyor.

Refresh token'ı tarayıcıda nerede saklayacağın ayrı bir karar. localStorage'daki bir refresh token, sayfadaki herhangi bir XSS (Cross-Site Scripting) açığıyla okunur; reuse detection çalınmayı fark eder ama engellemez. RFC 10017 (OAuth 2.0 for Browser-Based Applications) bu yüzden token'ları tarayıcıya hiç vermeyen BFF (Backend for Frontend) mimarisini en güvenli seçenek olarak öneriyor; serinin 14. yazısının konusu. Tarayıcıda token saklamanın tehdit modeli ilerideki Frontend Güvenliği serisinde.

Ölç: reuse warning'lerinin sayısı, 409 cevaplarının oranı ve iptal edilen family sayısı. Reuse sayısındaki ani bir artış ya bir istemci hatası (single-flight bozuk) ya da gerçek bir token sızıntısıdır; ikisi de hemen bakılması gereken şeyler.

Sık Düşülen Tuzaklar

Birincisi, refresh token'ı düz metin saklamak. Veritabanı yedeğine erişen herkes her kullanıcı adına oturum açabilir.

İkincisi, rotation yapıp reuse detection yapmamak. Eski token'ı yalnız "geçersiz" sayıp family'yi iptal etmezsen, token'ı çalan kişi meşru istemciden önce davrandığı sürece oturumu sürdürür ve kimse fark etmez.

Üçüncüsü, tüketimi oku-değiştir-yaz ile yapmak. Paralel iki istek aynı token'dan iki yeni token alır ve family ikiye bölünür.

Dördüncüsü, tolerans penceresi olmadan katı reuse detection'ı istemcide single-flight olmadan yayına almak. Kullanıcılar rastgele dışarı atılır ve ekip sorunu "refresh token bozuk" diye kapatıp korumayı kaldırır.

Beşincisi, refresh token'a absolute bir sınır koymamak. Sliding ömür tek başına bir oturumu, 2. yazıdaki cookie gibi, sonsuza kadar yaşatabilir.

Küçük Bir Deneme

★☆☆ Lab'da ReuseGrace'i sıfıra indir ve iki sekme senaryosunu tekrarla. Hangi cevabı alıyorsun, family'ye ne oluyor? Sonucu tablo üzerinden göster.

★★☆ Hash'i düz SHA-256 yerine sunucuda tutulan bir key ile HMAC (Hash-based Message Authentication Code) hesaplayacak şekilde, yani HMAC-SHA256 ile değiştir. Bunun veritabanı sızıntısı senaryosunda neyi değiştirdiğini ve key rotation'ı nasıl yapacağını iki paragrafta yaz.

★★★ Refresh token'a cihaz bağlamı ekle: login'de istemcinin gönderdiği bir cihaz public key'ini family'ye kaydet, her refresh isteğinde o key ile imzalanmış bir kanıt iste. Bunun DPoP'un (RFC 9449) basitleştirilmiş bir hâli olduğunu ve reuse detection'ın hangi senaryoyu artık gereksiz kıldığını açıkla.

Token'ların taşıdığı kimliği ve onu nasıl iptal edeceğimizi kurduk. Bir sonraki yazıda bu token'ları üreten tarafa geçiyoruz: OAuth 2.0'ın rolleri, grant type'ları ve scope'ları, RFC 9700 ile gelen güncel öneriler ve OAuth 2.1 taslağının bunları nasıl topladığı.