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

ASP.NET Core Cookie Authentication Derinlemesine: Session Ömrü, Sliding Expiration, Server-Side Session ve Revocation

Mert Özen 8 Kas 2026 11 dk 3 görüntülenme
ASP.NET Core Cookie Authentication Derinlemesine: Session Ömrü, Sliding Expiration, Server-Side Session ve Revocation

Sliding expiration tek başına bir session'ı sonsuza kadar yaşatabilir; absolute timeout'u OnValidatePrincipal ile kendin eklemen, iptal için de sunucuda bir durum (ITicketStore ya da versiyon damgası) tutman gerekir. Cookie ticket'ının içini, 14 günlük varsayılanı, yarı pencerede yenilemeyi, chunking'i, Redis'te server-side session'ı ve tüm cihazlardan çıkışı gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10.0.13 runtime), StackExchange.Redis 3.4.31 ve Redis 8.2 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un cookie authentication ayarı şu: ExpireTimeSpan = 30 dakika, SlidingExpiration = true. Destek ekibinden Alan sabah 9'da giriş yapıyor ve gün boyu her 10 dakikada bir sipariş ekranını yeniliyor. Session'ı saat kaçta biter?

Cevap: hiç bitmez. Sliding expiration her aktif istekte süreyi ileri iter; Alan sekmeyi kapatmadığı sürece cookie haftalarca geçerli kalabilir. Cookie'si çalınmış bir kullanıcı için de aynısı geçerli: saldırgan her 10 dakikada bir istek atarsa session'ı sonsuza kadar yaşatır. Bu yazıda cookie authentication'ın içini açıyoruz: ticket'ta ne var, ömrü nasıl hesaplanıyor, sliding'e nasıl bir üst sınır koyarsın ve bir session'ı sunucudan gerçekten nasıl öldürürsün. Cookie flag'leri ve session fixation Web Güvenliği (OWASP Top 10) serisinin 13. yazısında anlatıldı; buradaki odak ömür ve iptal.

Cookie'nin İçinde Ne Var?

ASP.NET Core'un cookie'si bir session kimliği değil, ticket'ın kendisidir. SignInAsync çağrıldığında handler AuthenticationTicket'ı (principal, claim'ler ve properties) TicketSerializer ile binary'ye çevirir, Data Protection ile şifreleyip imzalar ve Base64Url ile cookie'ye yazar. Değerin hep CfDJ8 ile başlaması tesadüf değil: Data Protection payload'ının sabit magic header'ının Base64 görünümü bu. Data Protection'ın kendisi ve key ring'in neden paylaşılması gerektiği Web Güvenliği serisinin 8. yazısında anlatıldı; burada önemli olan iki sonuç var.

Birincisi, sunucu durumsuzdur. Ticket'ı çözmek için anahtar yeter, veritabanına gitmez. İkincisi, cookie büyüdükçe her istek büyür. Lab'da login'e deneme amaçlı permission claim'leri ekleyip Set-Cookie'ı ölçtüm:

ExtraClaims=0    bookstore.auth   475 byte
ExtraClaims=40   bookstore.auth   3248 byte
ExtraClaims=120  bookstore.auth=chunks-3
                 bookstore.authC1 4000 byte
                 bookstore.authC2 4000 byte
                 bookstore.authC3 795 byte

Tarayıcılar tek bir cookie için yaklaşık 4 KB sınır uygular. ASP.NET Core bu yüzden ChunkingCookieManager kullanır: varsayılan ChunkSize 4050 karakter; değer bunu aşınca cookie'yi parçalara böler, ana cookie'ye de chunks-3 yazar. İşe yarıyor, ama her istekte tarayıcı 9 KB'ı aşan bir Cookie header'ı gönderiyor demek. Önündeki reverse proxy'nin header sınırına takılırsan (nginx'in varsayılan large_client_header_buffers ayarı 4 adet 8 KB'tır) kullanıcı uygulamaya hiç ulaşamaz ve hatayı kendi tarayıcısında görür. Claim'leri cookie'ye doldurmak yerine küçük tut ya da birazdan göreceğimiz server-side session'a geç.

ExpireTimeSpan, IsPersistent ve Sliding Expiration

Varsayılanları hafızadan yazmak yerine yazdırdım:

var cookieDefaults = new CookieAuthenticationOptions();
Console.WriteLine($"ExpireTimeSpan={cookieDefaults.ExpireTimeSpan} SlidingExpiration={cookieDefaults.SlidingExpiration}");
Console.WriteLine($"ChunkSize={ChunkingCookieManager.DefaultChunkSize}");
Console.WriteLine($"SecurityStamp ValidationInterval={new SecurityStampValidatorOptions().ValidationInterval}");
ExpireTimeSpan=14.00:00:00 SlidingExpiration=True
ChunkSize=4050
SecurityStamp ValidationInterval=00:30:00

On dört gün ve sliding açık: bir kitapçı için bile çok uzun. Burada iki ayrı süre var ve sık karıştırılıyor. ExpireTimeSpan ticket'ın içindeki ExpiresUtc'yi belirler; sunucu her istekte buna bakar ve süresi geçmiş ticket'ı reddeder. Cookie'nin tarayıcıdaki ömrü ise IsPersistent'e bağlıdır. IsPersistent = false ise cookie'de expires yoktur ve tarayıcı kapanınca silinir (modern tarayıcıların "kaldığın yerden devam et" özelliği bu cookie'leri de geri getirebilir). true ise expires ticket'ın bitişine eşitlenir. Güvenliği belirleyen sunucudaki ExpiresUtc'dir; tarayıcı ömrü bir kolaylık ayarıdır.

Sliding expiration'ın kuralı da net: istek, süre penceresinin yarısını geçmişse handler yeni bir ticket üretir ve cookie'yi yeniden yazar. Etkisini görmek için lab'ı 20 saniyelik idle ve 45 saniyelik absolute süreyle çalıştırdım (production değerleri appsettings.json'da 30 dakika ve 8 saat):

{
  "SessionLifetime": {
    "Idle": "00:30:00",
    "Absolute": "08:00:00"
  }
}
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.Name = "bookstore.auth";
        options.ExpireTimeSpan = lifetime.Idle;
        options.SlidingExpiration = true;
        options.Events.OnValidatePrincipal = SessionValidator.ValidateAsync;
    });

Ada giriş yapıyor, sonra aralıklarla /me'yi çağırıyor. Her satırda geçen süre, cevapta Set-Cookie olup olmadığı ve ticket'ın issuedUtc/expiresUtc değerleri var:

--- t=5s
HTTP/1.1 200 OK
{"name":"ada@bookstore.test","issuedUtc":"2026-11-08T05:28:03+00:00","expiresUtc":"2026-11-08T05:28:23+00:00","authTime":"2026-11-08T05:28:03.1934430+00:00"}
--- t=12s
HTTP/1.1 200 OK
Set-Cookie: bookstore.auth=CfDJ8EINq_wm4TFCv7guzy6SHCfG5f4xuARXUq-Et7jj...
{"name":"ada@bookstore.test","issuedUtc":"2026-11-08T05:28:03+00:00","expiresUtc":"2026-11-08T05:28:23+00:00","authTime":"2026-11-08T05:28:03.1934430+00:00"}
--- t=21s
HTTP/1.1 200 OK
{"name":"ada@bookstore.test","issuedUtc":"2026-11-08T05:28:15+00:00","expiresUtc":"2026-11-08T05:28:35+00:00","authTime":"2026-11-08T05:28:03.1934430+00:00"}
--- t=30s
HTTP/1.1 200 OK
Set-Cookie: bookstore.auth=CfDJ8EINq_wm4TFCv7guzy6SHCc33HMzsEg2b9ijesPr...
{"name":"ada@bookstore.test","issuedUtc":"2026-11-08T05:28:15+00:00","expiresUtc":"2026-11-08T05:28:35+00:00","authTime":"2026-11-08T05:28:03.1934430+00:00"}
--- t=39s
HTTP/1.1 200 OK
{"name":"ada@bookstore.test","issuedUtc":"2026-11-08T05:28:33+00:00","expiresUtc":"2026-11-08T05:28:53+00:00","authTime":"2026-11-08T05:28:03.1934430+00:00"}
--- t=48s
HTTP/1.1 401 Unauthorized
Set-Cookie: bookstore.auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax; httponly

Beşinci saniyede pencerenin yarısı dolmadığı için cookie yenilenmedi. On ikinci saniyede yenilendi; yeni ticket bir sonraki istekte görünüyor: issuedUtc 05:28:15'e, expiresUtc 05:28:35'e kaydı. Bu iki satır önemli bir ayrıntıyı gösteriyor: sliding her yenilemede IssuedUtc'yi de değiştiriyor. Yani "kullanıcı ne zaman giriş yaptı?" sorusunu IssuedUtc'ye soramazsın. 48. saniyedeki 401 sliding'den değil, birazdan kuracağımız absolute sınırdan geliyor.

Hiç istek atmadan 22 saniye beklediğimde ise handler'ın kendisi reddetti ve log'a Cookies was not authenticated. Failure message: Ticket expired yazdı. Idle timeout budur: sliding açıkken ExpireTimeSpan aslında "en uzun hareketsizlik süresi" anlamına gelir. Belirli bir giriş için sliding'i kapatmak istersen AuthenticationProperties.AllowRefresh = false verebilirsin.

Absolute Timeout: OnValidatePrincipal ile Üst Sınır

Cookie handler'ın yerleşik bir absolute timeout ayarı yok. OWASP'ın (Open Worldwide Application Security Project) Session Management Cheat Sheet'i tam gün çalışan bir ofis kullanıcısı için 4 ile 8 saat arası bir absolute timeout öneriyor ve sürelerin sunucu tarafında uygulanması gerektiğini vurguluyor. Bunu OnValidatePrincipal event'i ile kuruyoruz. Bu event, ticket çözülüp süresi kontrol edildikten sonra her istekte çalışır ve principal'ı reddetme şansı verir.

Giriş anını IssuedUtc'ye bırakamadığımız için login'de Items'a ayrı bir değer yazıyoruz:

var properties = new AuthenticationProperties();
properties.Items["auth_time"] = time.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);

await context.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity(claims, "Cookies", "name", "role")), properties);

Validator da bu değeri okuyor:

public static class SessionValidator
{
    public static async Task ValidateAsync(CookieValidatePrincipalContext context)
    {
        IServiceProvider services = context.HttpContext.RequestServices;
        SessionLifetimeOptions lifetime = services.GetRequiredService<IOptions<SessionLifetimeOptions>>().Value;
        TimeProvider time = services.GetRequiredService<TimeProvider>();
        ILogger logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("Bookstore.Sessions");

        // IssuedUtc her sliding yenilemede değişir, bu yüzden üst sınır auth_time'a göre hesaplanır
        if (!context.Properties.Items.TryGetValue("auth_time", out string? raw)
            || DateTimeOffset.Parse(raw!, CultureInfo.InvariantCulture) + lifetime.Absolute < time.GetUtcNow())
        {
            logger.LogInformation("Session rejected: absolute lifetime exceeded for {User}", context.Principal?.Identity?.Name);
            context.RejectPrincipal();
            await context.HttpContext.SignOutAsync();
            return;
        }

        ClaimsPrincipal principal = context.Principal!;
        string userId = principal.FindFirstValue("sub")!;
        string current = await services.GetRequiredService<SessionVersionStore>().GetAsync(userId);
        if (principal.FindFirstValue("session_version") != current)
        {
            logger.LogInformation("Session rejected: session version changed for {User}", userId);
            context.RejectPrincipal();
            await context.HttpContext.SignOutAsync();
        }
    }
}

RejectPrincipal() request'i anonim yapar; SignOutAsync() da tarayıcıdaki cookie'yi siler, yoksa tarayıcı ölü cookie'yi göndermeye devam eder. auth_time değeri olmayan bir ticket'ı (örneğin bu kodu deploy etmeden önce verilmiş eski bir cookie'yi) da reddediyoruz: fail closed. Yukarıdaki çıktıdaki 48. saniye bu kodun sonucuydu ve log'da şu satır vardı:

info: Bookstore.Sessions[0]
      Session rejected: absolute lifetime exceeded for ada@bookstore.test

Validator'ın ikinci yarısı, yani session_version kontrolü, iptal bölümünde anlamını bulacak.

Server-Side Session: ITicketStore ve Redis

Cookie'ye ticket'ın tamamını koymak yerine yalnız bir anahtar koyup ticket'ı sunucuda tutabilirsin. CookieAuthenticationOptions.SessionStore'a bir ITicketStore verince handler ticket'ı store'a yazar, cookie'ye yalnızca store'un döndürdüğü anahtarı içeren küçük bir ticket koyar. Redis ile:

public sealed class RedisTicketStore(IConnectionMultiplexer redis, TimeProvider time) : ITicketStore
{
    private readonly IDatabase _db = redis.GetDatabase();

    public async Task<string> StoreAsync(AuthenticationTicket ticket)
    {
        string key = "session:" + Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
        await RenewAsync(key, ticket);
        string? userId = ticket.Principal.FindFirstValue("sub");
        if (userId is not null)
        {
            await _db.SetAddAsync($"user:{userId}:sessions", key);
        }
        return key;
    }

    public async Task RenewAsync(string key, AuthenticationTicket ticket)
    {
        byte[] data = TicketSerializer.Default.Serialize(ticket);
        // Redis key'i ticket kadar yaşar; sliding yenilemeler süreyi uzatır
        TimeSpan ttl = (ticket.Properties.ExpiresUtc ?? time.GetUtcNow().AddHours(1)) - time.GetUtcNow();
        await _db.StringSetAsync(key, data, ttl);
    }

    public async Task<AuthenticationTicket?> RetrieveAsync(string key)
    {
        RedisValue data = await _db.StringGetAsync(key);
        return data.IsNull ? null : TicketSerializer.Default.Deserialize((byte[])data!);
    }

    public Task RemoveAsync(string key) => _db.KeyDeleteAsync(key);

    public async Task<int> RemoveAllForUserAsync(string userId)
    {
        string setKey = $"user:{userId}:sessions";
        RedisValue[] keys = await _db.SetMembersAsync(setKey);
        foreach (RedisValue key in keys)
        {
            await _db.KeyDeleteAsync((string)key!);
        }
        await _db.KeyDeleteAsync(setKey);
        return keys.Length;
    }
}

Store'u cookie options'ına DI (Dependency Injection) üzerinden bağlıyoruz; AddCookie'nin lambda'sında service'e erişemediğin için named options kullanılıyor:

builder.Services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
    .Configure<RedisTicketStore>((options, store) => options.SessionStore = store);

120 ekstra claim'le giriş yapınca, üç parçaya bölünen 9 KB'lık cookie'nin yerini 304 karakterlik tek bir cookie aldı. Ticket Redis'e gitti:

$ docker exec authwork1-redis redis-cli KEYS '*'
session:af1d9fbfac5145347058f49e804071be
session:da27711f9ba69bb61d162805a45350e4
session:2011b13ed7303e9e40c8489200c6f478
user:ada@bookstore.test:sessions
$ docker exec authwork1-redis redis-cli TTL session:af1d9fbfac5145347058f49e804071be
1800
$ docker exec authwork1-redis redis-cli GET session:af1d9fbfac5145347058f49e804071be | strings | head -5
Cookies
Cookies
name
role{
ada@bookstore.test

Üç şey dikkat çekiyor. TTL (Time To Live) 1800 saniye, yani idle süresiyle aynı; sliding her yenilemede RenewAsync'i çağırıp süreyi uzatıyor. Ada iki cihazdan girdi ama üç session var: laptop'tan logout yapmadan ikinci kez giriş yapınca ilk session sahipsiz kaldı ve TTL dolana kadar Redis'te duracak. Üçüncüsü daha ciddi: TicketSerializer şifrelemez, claim'ler Redis'te düz metin. Cookie'deki ticket Data Protection ile korunuyordu; Redis'e taşıyınca bu koruma kayboldu. Redis'i ağdan izole et, kimlik doğrulamasını aç ya da ticket'ı yazmadan önce bir IDataProtector ile şifrele.

Revocation: "Tüm Cihazlardan Çıkış"

SignOutAsync yalnız o tarayıcıdaki cookie'yi siler. Stateless bir cookie kopyalanmışsa, kopyası süresi dolana kadar geçerlidir; sunucunun "bu ticket'ı artık kabul etme" diyebileceği bir yer yoktur. İptal için sunucuda bir durum gerekir ve iki yolu var.

Server-side session kullanıyorsan iptal, store'dan silmektir. Ada laptop'tan "tüm cihazlardan çıkış"a basınca:

app.MapPost("/sessions/revoke-all", async (HttpContext context, SessionVersionStore versions, RedisTicketStore store) =>
{
    string userId = context.User.FindFirstValue("sub")!;
    await versions.BumpAsync(userId);
    int removed = await store.RemoveAllForUserAsync(userId);
    await context.SignOutAsync();
    return TypedResults.Ok(new { removedServerSessions = removed });
}).RequireAuthorization();
laptop 200
phone 200
$ curl -s -b d1.txt -c d1.txt -X POST http://localhost:19120/sessions/revoke-all
{"removedServerSessions":3}
phone 401

info: Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler[7]
      Cookies was not authenticated. Failure message: Identity missing in session store

Telefonun cookie'si hâlâ geçerli bir şekilde şifrelenmiş ve imzalı; ama içindeki anahtarın karşılığı Redis'te yok, handler "Identity missing in session store" diyor.

Stateless cookie'de kalmak istiyorsan ikinci yol bir versiyon damgası: login'de kullanıcının o anki session_version'ını claim olarak ticket'a yaz, OnValidatePrincipal'da güncel değerle karşılaştır, iptal ederken değeri artır. Store olmadan aynı senaryo:

phone 200
{"removedServerSessions":0}
HTTP/1.1 401 Unauthorized
Set-Cookie: bookstore.auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax; httponly

info: Bookstore.Sessions[0]
      Session rejected: session version changed for grace@bookstore.test

ASP.NET Core Identity bu fikrin hazır hâlini sunar: SecurityStamp. Parola değişince, 2FA (Two-Factor Authentication) ayarı değişince ya da sen UpdateSecurityStampAsync çağırınca stamp yenilenir ve SecurityStampValidator eski stamp'li cookie'leri reddeder. Fark şu: Identity her istekte veritabanına gitmez, yukarıda yazdırdığımız ValidationInterval (varsayılan 30 dakika) aralıklarla kontrol eder. Yani Identity'de "tüm cihazlardan çıkış" varsayılan ayarla 30 dakikaya kadar gecikebilir. Bu aralığı kısaltmak her istekte bir sorgu daha demek; Identity'nin bu tarafı 3. ve 4. yazılarda.

.NET 10'da API İstekleri: Redirect Yerine 401/403

.NET 10 öncesinde cookie handler süresi dolmuş bir session'da da login sayfasına 302 dönüyordu ve Angular istemcisi 401 yerine HTML (HyperText Markup Language) alıyordu. .NET 10'da IApiEndpointMetadata taşıyan endpoint'ler ([ApiController], JSON (JavaScript Object Notation) okuyan/yazan minimal API (Application Programming Interface)'ler, TypedResults dönenler) 401/403 alıyor. Yukarıdaki çıktılarda 302 değil 401 görmen bu yüzden; lab'daki tüm endpoint'ler TypedResults dönüyor. Results.Ok dönen bir endpoint'in neden hâlâ redirect ettiğini serinin 1. yazısında gördük.

Üretimde Dikkat

Ölç ve sınırla. OnValidatePrincipal her istekte çalışır; içine koyduğun her Redis ya da veritabanı çağrısı her isteğin gecikmesine eklenir. Versiyon kontrolünü her istekte yapmak yerine Identity gibi bir aralıkla yapabilirsin (örneğin son kontrol zamanını Items'a yazıp 1 dakikada bir), bu durumda iptal gecikmesini bilinçli olarak kabul etmiş olursun. Hangi değerin doğru olduğu, iptalin ne kadar acil olduğuna bağlı: bir bankada saniyeler, bir kitapçıda dakikalar.

Redis'in erişilemez olduğu anı düşün. Server-side session'da Redis yoksa kimse giriş yapamaz ve kimse kalamaz; versiyon damgasında da her istek hata verir. Bunu bilerek seç: fail closed (güvenli, ama kesinti demek) mi, yoksa kısa bir süre son bilinen değerle devam etmek mi? Kimlik tarafında genelde doğru cevap fail closed.

Birden fazla instance çalıştırıyorsan iki şey paylaşılmalı: Data Protection key ring'i (yoksa bir instance'ın yazdığı cookie'yi diğeri çözemez) ve session store. Sahipsiz session'lar için de set'leri temizle; yukarıdaki user:...:sessions set'i, TTL'i dolup silinen session anahtarlarını kendiliğinden bırakmaz. RemoveAllForUserAsync zararsızca silinmiş anahtarları da dener, ama set'in büyümesini engellemek için set'e de bir TTL koy ya da periyodik temizlik yap.

Sık Düşülen Tuzaklar

Birincisi, absolute timeout'u IssuedUtc ile hesaplamak. Sliding her yenilemede IssuedUtc'yi güncellediği için bu kontrol hiçbir zaman tetiklenmez; giriş anını ayrıca saklaman gerekir.

İkincisi, ExpireTimeSpan'ı cookie'nin tarayıcıdaki ömrü sanmak. Session cookie'sinin tarayıcıda expires'ı yoktur ama ticket'ın sunucudaki süresi vardır; ikisini ayrı düşün.

Üçüncüsü, SignOutAsync'i iptal sanmak. Logout yalnız o tarayıcıdan çıkış yapar. Parola değişikliği, hesap kilitleme ve "şüpheli giriş" senaryolarında sunucu tarafında bir iptal mekanizman olmalı.

Dördüncüsü, ITicketStore'a taşıyınca ticket'ın şifrelenmediğini unutmak. Redis'teki claim'ler düz metin; Redis'e erişen herkes kullanıcıların claim'lerini okur.

Beşincisi, RejectPrincipal()'dan sonra SignOutAsync() çağırmamak. Request anonim olur ama tarayıcı geçersiz cookie'yi her istekte göndermeye devam eder ve validator her seferinde aynı işi yapar.

Küçük Bir Deneme

★☆☆ Lab'ı --SessionLifetime:Idle=00:00:20 ile çalıştır, login'de AllowRefresh = false ver ve her 5 saniyede bir istek at. Cookie'nin artık yenilenmediğini ve 20. saniyede session'ın bittiğini Set-Cookie satırlarıyla göster.

★★☆ RedisTicketStore'u, Redis'e yazmadan önce ticket byte'larını bir IDataProtector ile şifreleyecek şekilde değiştir. redis-cli GET çıktısında e-posta adresinin artık görünmediğini doğrula.

★★★ Versiyon kontrolünü her istekte değil, en fazla dakikada bir yapacak şekilde değiştir: son kontrol zamanını context.Properties.Items'a yaz ve değiştiğinde context.ShouldRenew = true ile cookie'yi yenile. Ardından "tüm cihazlardan çıkış" ile telefonun düşmesi arasındaki en uzun gecikmeyi ölç.

Cookie'nin ömrünü ve iptalini kendimiz kurduk. Bir sonraki yazıda bu işin büyük kısmını hazır getiren ASP.NET Core Identity'nin mimarisine geçiyoruz: UserManager, SignInManager, store'lar ve kendi user entity'ni nasıl tasarlayacağın.