Mert Özen Her Satırda Daha İleri
Güvenlik Web Güvenliği (OWASP Top 10) — #13

OWASP A07: Authentication Failures (2) — Cookie Bayrakları, JWT Doğrulama ve CSRF

Mert Özen 6 Kas 2026 10 dk 3 görüntülenme
OWASP A07: Authentication Failures (2) — Cookie Bayrakları, JWT Doğrulama ve CSRF

Girişten sonra kimliğin her istekte nasıl taşındığına bakıyoruz. Cookie bayraklarını ve __Host- önekini açıkça ayarlıyor, session fixation riskini anlatıyor, JwtBearer'da issuer, audience, süre, imza ve algoritmayı doğruluyor, süresi dolan token'da ClockSkew'un etkisini saniye saniye ölçüyor ve antiforgery ile SameSite'ın CSRF'e karşı birlikte nasıl çalıştığını gösteriyoruz.

Sürüm notu: Bu yazı OWASP Top 10:2025'e göre yazılmıştır; kodlar ASP.NET Core 10 (.NET 10) ile denenmiştir.

Önce Tahmin Et

Bookstore API'ye giriş yaptın ve sunucu sana bir cookie verdi. Bu cookie'yi JavaScript okuyabilir mi? Başka bir sitenin sayfasındaki bir form, senin tarayıcından Bookstore API'ye istek gönderirse o cookie de isteğe eklenir mi? Bir de JWT tarafı: süresi beş saniye önce dolmuş bir token'la istek atarsan API ne der?

Son sorunun cevabı çoğu kişinin beklediği gibi değil. Önceki yazıda kapıdan kimin geçtiğini doğrulamıştık; bu yazıda o kimliğin sonraki isteklerde nasıl taşındığına bakıyoruz.

Kimlik Her İstekte Taşınır

HTTP durumsuzdur; sunucu bir önceki isteği hatırlamaz. Giriş başarılı olduğunda sunucu istemciye bir kanıt verir ve istemci bu kanıtı sonraki her istekte geri gönderir. Kanıt ya bir cookie'dir (tarayıcı onu otomatik ekler) ya da bir token'dır (istemci kodu onu Authorization header'ına kendisi koyar). İki yolun riskleri farklı. Cookie'yi tarayıcı otomatik eklediği için asıl tehdit CSRF; token'ı JavaScript tuttuğu için asıl tehdit XSS ile çalınması.

A07:2025 Authentication Failures altındaki CWE'lerden biri doğrudan bu konuyla ilgili: session fixation (CWE-384). Saldırgan, kurbanın kullanacağı session kimliğini girişten önce belirleyebiliyorsa ve sunucu girişten sonra aynı kimliği kullanmaya devam ediyorsa, o kimlikle kurbanın oturumuna sahip olur. Savunması giriş anında yeni bir kimlik üretmek.

ASP.NET Core'un cookie authentication'ında girişten önce bir kimlik yok; SignInAsync o anda Data Protection ile şifrelenip imzalanmış yeni bir ticket üretiyor. Risk, giriş durumunu AddSession ile gelen ISession'da tutan kodda: ISession'ın girişte kimliği yenileyen bir method'u yok. Giriş bilgisini authentication cookie'sine bırak, session'ı sepet gibi hassas olmayan veriler için kullan.

Örneklerin hepsi kendi makinendeki lab'da çalışıyor; bu teknikleri yalnız sahibi olduğun ya da yazılı izin aldığın sistemlerde dene.

Önce hiçbir şey ayarlamadan cookie authentication'ın ne ürettiğine bakalım. Lab'ı aynı anda HTTP ve HTTPS üzerinden açıp iki tarafta da giriş yapınca:

$ curl -i -X POST http://localhost:5152/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"Dev-only-Bookstore-2026"}'
HTTP/1.1 200 OK
...
Set-Cookie: .AspNetCore.Cookies=CfDJ8...; path=/; samesite=lax; httponly
$ curl -i -X POST https://localhost:5151/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"Dev-only-Bookstore-2026"}'
HTTP/2 200
...
set-cookie: .AspNetCore.Cookies=CfDJ8...; path=/; secure; samesite=lax; httponly

Varsayılanlar fena değil: HttpOnly açık, SameSite=Lax. Ama Secure bayrağı isteğe göre değişiyor. SecurePolicy'nin varsayılanı SameAsRequest; istek HTTP ile geldiyse cookie de Secure olmadan gidiyor. Production'da TLS'i bir reverse proxy sonlandırıyorsa ve forwarded header'lar doğru ayarlanmamışsa uygulama her isteği HTTP sanar ve cookie'lerin hepsi bayraksız çıkar. Bu yüzden bayrakları açıkça yazıyorum:

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        // __Host- öneki: Secure, Path=/ ve Domain yok şartlarını tarayıcıya zorlatır
        options.Cookie.Name = "__Host-bookstore";
        options.Cookie.HttpOnly = true;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.SameSite = SameSiteMode.Lax;
        options.ExpireTimeSpan = TimeSpan.FromHours(8);
        options.SlidingExpiration = true;
    })

Her bayrağın tek bir işi var. HttpOnly, cookie'yi document.cookie'den gizler; sayfada bir XSS açığı olsa bile script cookie'yi okuyup dışarı gönderemez. Secure, cookie'nin yalnızca HTTPS üzerinden gönderilmesini sağlar. SameSite, başka bir siteden başlatılan isteklerde cookie'nin gönderilip gönderilmeyeceğini belirler; CSRF bölümünde ona döneceğiz. __Host- öneki de bir tarayıcı kuralı: bu önekle başlayan bir cookie'yi tarayıcı yalnızca Secure, Path=/ ve Domain'siz gelirse kabul eder, böylece bir alt alan adı cookie'nin üzerine yazamaz. Varsayılan ömür 14 gün; bir kitapçı için 8 saat yeterli.

$ curl -i -X POST https://localhost:5151/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"Dev-only-Bookstore-2026"}'
HTTP/2 200
...
set-cookie: __Host-bookstore=CfDJ8...; path=/; secure; samesite=lax; httponly

{"message":"Welcome"}
$ curl -i https://localhost:5151/me
HTTP/2 401
...
location: https://localhost:5151/Account/Login?ReturnUrl=%2Fme
content-length: 0

Cookie'de expires yok, yani tarayıcı kapanınca silinen bir oturum cookie'si; 8 saatlik süre cookie'nin içindeki ticket'ta duruyor. İkinci istekte de .NET 10'un bir değişikliğini görüyorsun: JSON dönen bir API endpoint'i için cookie authentication artık giriş sayfasına yönlendirmiyor, 401 dönüyor. location header'ı hâlâ yazılıyor ama durum kodu 302 değil 401.

Çıkışta SignOutAsync yalnızca tarayıcıdaki cookie'yi siler; kopyalanmış bir cookie, ticket'ın süresi dolana kadar geçerli kalır. Sunucu tarafında iptal için SessionStore (ITicketStore) seçeneğine ya da Identity'nin security stamp doğrulamasına bak.

JWT: Okumak Başka, Doğrulamak Başka

JWT üç parçadan oluşur: header, payload ve imza. İlk iki parça yalnızca Base64Url ile kodlanmıştır; şifreli değildir, herkes okuyabilir. Güven imzadan gelir. JWT ile ilgili hataların çoğu bu ayrımı kaçırmaktan çıkıyor.

İlki, token'ı doğrulamadan okumak. Payload'ı çözüp içindeki sub ya da role değerine güvenen kod, herkesin yazabileceği bir metne güveniyor demektir. İkincisi, algoritmayı token'a sordurmak. Header'daki alg alanı token'ı üretenin beyanıdır; hangi algoritmanın kabul edileceğine doğrulayan taraf karar vermeli. Bu kararı token'a bırakan kütüphaneler geçmişte imzasız token'ları kabul etmek gibi hatalara düştü. Üçüncüsü, issuer ve audience'ı kontrol etmemek; o zaman başka bir servis için üretilmiş token senin API'nde de geçer. Dördüncüsü, uzun ömür: JWT'yi sunucu tarafında iptal etmek zordur, bir günlük access token çalınırsa bir gün boyunca geçerlidir.

ASP.NET Core'da doğrulamayı AddJwtBearer yapıyor. Lab'daki yapılandırma şöyle:

    .AddJwtBearer(options =>
    {
        options.MapInboundClaims = false;
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = jwt.Issuer,
            ValidateAudience = true,
            ValidAudience = jwt.Audience,
            ValidateLifetime = true,
            RequireExpirationTime = true,
            // Varsayılan 5 dakika; kısa ömürlü token'da bu çok fazla
            ClockSkew = TimeSpan.FromSeconds(30),
            RequireSignedTokens = true,
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = signingKey,
            // Algoritmayı token değil sunucu seçer
            ValidAlgorithms = [SecurityAlgorithms.HmacSha256]
        };
    });

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("api", policy => policy
        .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
        .RequireAuthenticatedUser());

Bu değerlerin bir kısmı zaten varsayılan. Boş bir TokenValidationParameters'ı yazdırınca:

ValidateIssuer=True ValidateAudience=True ValidateLifetime=True RequireExpirationTime=True RequireSignedTokens=True ValidateIssuerSigningKey=False ClockSkew=00:05:00

Yine de açıkça yazıyorum; kodu okuyan neyin doğrulandığını tahmin etmek zorunda kalmasın. ValidAlgorithms ise varsayılanda boş, yani anahtara uyan her algoritma kabul edilir; listeyi sen daralt. İmza anahtarının adı dev-only-... ile başlıyor; gerçek uygulamada anahtar A04 yazısında anlattığım gibi user-secrets, ortam değişkeni ya da bir vault'tan gelir. /api/orders endpoint'i RequireAuthorization("api") ile bu policy'ye bağlı:

$ curl -i https://localhost:5151/api/orders
HTTP/2 401
www-authenticate: Bearer
content-length: 0

$ curl -i https://localhost:5151/api/orders -H "Authorization: Bearer not-a-jwt"
HTTP/2 401
www-authenticate: Bearer error="invalid_token"
content-length: 0

$ curl -i -b jar.txt https://localhost:5151/api/orders
HTTP/2 401
www-authenticate: Bearer
content-length: 0

$ curl -i https://localhost:5151/api/orders -H "Authorization: Bearer $TOKEN"
HTTP/2 200
content-type: application/json; charset=utf-8

{"user":"ada@example.com","orders":[1001,1002]}

Token yoksa 401, bozuk bir token varsa invalid_token ile 401. Üçüncü istek ilginç: giriş cookie'si geçerli ama policy yalnızca Bearer şemasını kabul ettiği için cookie bu endpoint'te işe yaramıyor. Hangi endpoint'in hangi kimlik kanıtını kabul ettiği böyle açıkça tanımlı olmalı. Dördüncüde /token endpoint'inden doğru parolayla alınmış token kullanılıyor.

Süresi Dolan Token ve ClockSkew

Lab'ı Jwt__LifetimeSeconds=5 ortam değişkeniyle başlattım; token beş saniye yaşıyor. Token'ı alıp hemen, 20 saniye sonra ve 37 saniye sonra aynı isteği gönderiyorum:

Fri Nov  6 19:14:29 UTC 2026
200
after 20s 200
Fri Nov  6 19:15:06 UTC 2026
HTTP/2 401
www-authenticate: Bearer error="invalid_token", error_description="The token expired at '11/06/2026 19:14:34'"
content-length: 0

Token 19:14:34'te doldu ama 20 saniye sonra hâlâ kabul edildi. Sebep ClockSkew: sunucular arasındaki saat farkını tolere etmek için süre dolumuna eklenen pay. Biz 30 saniyeye indirdik ve token ancak 30 saniyelik pay da bitince reddedildi. Varsayılan 5 dakika; 5 dakikalık bir access token aslında 10 dakika yaşar. Önce Tahmin Et'teki sorunun cevabı buydu.

Token nerede saklanmalı? OWASP'ın HTML5 Security Cheat Sheet'i session kimliklerini localStorage'da tutmamayı söylüyor; sayfadaki her script o veriye erişebilir ve tek bir XSS hepsini götürür. SPA için daha güvenli yol, token'ı tarayıcıya hiç vermeyen ve HttpOnly, Secure, SameSite bir cookie kullanan backend-for-frontend (BFF) yaklaşımı. Token'ı istemcide tutmak zorundaysan bellekte ve kısa ömürlü tut.

CSRF: Ayrı Başlığı Yok, Kendisi Duruyor

CSRF'in Top 10'da artık kendi başlığı yok; 2025 listesinde CWE-352 olarak A01 Broken Access Control'ün altında duruyor. Mekanizması cookie'nin doğasından geliyor: tarayıcı bir siteye giden isteğe o sitenin cookie'lerini ekler, isteği hangi sayfanın başlattığına bakmadan. Başka bir sitedeki bir form senin tarayıcından Bookstore API'ye post edilirse ve cookie'n isteğe eklenirse, sunucu açısından bu istek senden gelmiş gibi görünür.

İlk savunma hattı SameSite. Lax cookie, başka bir siteden başlatılan POST'larda ve alt kaynak isteklerinde gönderilmez, ama kullanıcı bir linke tıklayıp siteye üst seviye bir GET ile geldiğinde gönderilir. Bu yüzden GET isteği asla durum değiştirmemeli. Strict o GET'te de cookie'yi göndermez; güvenlidir ama e-postadaki bir linkten gelen kullanıcı kendini çıkış yapmış gibi görür. Tarayıcı varsayılanına güvenme: SameSite yazılmamış cookie'yi Lax sayan davranış Chrome'da var, Firefox'ta yalnızca bir ayarın arkasında, Safari'de yok. Ayrıca SameSite "site"a bakar, "origin"e değil; shop.example.com ile blog.example.com aynı sitedir. Bu yüzden ikinci hat da lazım: antiforgery token.

builder.Services.AddAntiforgery(options =>
{
    options.Cookie.Name = "__Host-bookstore-af";
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});

WebApplication app = builder.Build();
// AddAntiforgery yalnızca servisleri kaydeder; doğrulamayı bu satır açar
app.UseAntiforgery();
...
app.MapGet("/reviews/new", (HttpContext http, IAntiforgery antiforgery) =>
{
    AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(http);
    string html = $"""
        <form method="post" action="/reviews">
          <input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}" />
          <input name="bookId" /> <textarea name="text"></textarea>
          <button>Send</button>
        </form>
        """;
    return Results.Content(html, "text/html");
}).RequireAuthorization();

app.MapPost("/reviews", ([FromForm] ReviewForm form, ClaimsPrincipal user) =>
        Results.Ok(new { saved = true, by = user.Identity!.Name, form.BookId }))
    .RequireAuthorization();

Antiforgery bir çift kullanır: biri cookie'de, biri formun gizli alanında. İkisi birbiriyle ve giriş yapmış kullanıcıyla eşleşmeli. Başka bir site formu senin tarayıcından gönderebilir, ama same-origin policy yüzünden senin sayfandaki gizli alanın değerini okuyamaz. .NET 8'den beri [FromForm] bağlayan minimal API endpoint'leri varsayılan olarak doğrulanıyor; şartı UseAntiforgery() satırının pipeline'da olması. Sonuçlar:

$ curl -i -b jar.txt -c jar.txt https://localhost:5151/reviews/new
HTTP/2 200
content-type: text/html
...
set-cookie: __Host-bookstore-af=CfDJ8...; path=/; secure; samesite=strict; httponly
x-frame-options: SAMEORIGIN

<form method="post" action="/reviews">
  <input type="hidden" name="__RequestVerificationToken" value="CfDJ8..." />
...
$ curl -i -b jar.txt -X POST https://localhost:5151/reviews -d "bookId=1&text=Great"
HTTP/2 400
content-length: 0

$ curl -i -b jar.txt -X POST https://localhost:5151/reviews --data-urlencode "__RequestVerificationToken=$TOKEN" -d "bookId=1&text=Great"
HTTP/2 200
content-type: application/json; charset=utf-8

{"saved":true,"by":"ada@example.com","bookId":1}

Token olmadan gelen post 400 ile reddedildi, endpoint'in koduna hiç girilmedi. Antiforgery cookie'si kendiliğinden samesite=strict ve httponly geldi, üstüne bir de X-Frame-Options: SAMEORIGIN header'ı eklendi; formu başka bir sitenin iframe'ine gömmek de engellenmiş oldu.

JSON gövde alan endpoint'ler otomatik doğrulanmaz. Cookie ile çalışan bir JSON API'n varsa token'ı header'da beklemelisin: Angular'ın HttpClient'ı XSRF-TOKEN cookie'sini okuyup X-XSRF-TOKEN header'ına koyar, sunucuda options.HeaderName = "X-XSRF-TOKEN" ve IAntiforgery.ValidateRequestAsync eşleşmeyi tamamlar. Bearer token kullanan endpoint'ler CSRF'ten etkilenmez, çünkü tarayıcı Authorization header'ını kendiliğinden eklemez; .DisableAntiforgery() yalnızca onlar için düşünülmeli. learn.microsoft.com'daki .NET 11 belgeleri Sec-Fetch-Site ve Origin header'larına bakan, token gerektirmeyen otomatik bir CSRF middleware'i anlatıyor; .NET 10'da UseAntiforgery hâlâ senin işin.

Sık Düşülen Tuzaklar

İlk tuzak, SecurePolicy = Always yazıp uygulamayı HTTP üzerinden test etmek. Lab'ı ilk denememde HTTP ile açtım ve formu isteyen ilk istek 500 döndü. Logdaki sebep açıktı: The antiforgery system has the configuration value AntiforgeryOptions.Cookie.SecurePolicy = Always, but the current request is not an SSL request. Çözüm ayarı gevşetmek değil, geliştirmede de HTTPS dev sertifikasını kullanmak; lab'ın HTTPS üzerinden çalışmasının sebebi bu.

İkincisi, kısa bir imza anahtarı. Anahtarı dev-only-short-key yapınca token üretimi 500 ile patladı: IDX10720: Unable to create KeyedHashAlgorithm for algorithm 'HS256', the key size must be greater than: '256' bits, key has '144' bits. Kütüphane burada seni koruyor; HS256 için en az 32 baytlık rastgele bir anahtar üret.

Üçüncüsü, ClockSkew'u unutmak ve token ömrünü kısalttığını sanmak. Dördüncüsü, CSRF koruması olarak CORS'a güvenmek. CORS, başka bir sitenin cevabı okuyup okuyamayacağını belirler; isteğin gönderilmesini engellemez. Beşincisi, durum değiştiren bir GET endpoint'i yazmak; SameSite=Lax o isteği korumaz. Altıncısı, cookie ile çalışan bir endpoint'e "hata veriyordu" diye .DisableAntiforgery() eklemek.

Kontrol Listesi

Authentication cookie'n HttpOnly, Secure (SecurePolicy.Always) ve açıkça yazılmış bir SameSite ile mi çıkıyor? Mümkünse __Host- önekini kullanıyor musun? Cookie ve ticket ömrü uygulamanın ihtiyacına göre kısaltıldı mı? Giriş durumunu ISession'da değil authentication cookie'sinde mi tutuyorsun?

JWT doğrulamasında issuer, audience, süre ve imza açıkça doğrulanıyor mu, ValidAlgorithms daraltıldı mı, ClockSkew bilinçli bir değer mi? İmza anahtarı en az 256 bit ve kodun dışında mı? Access token'lar kısa ömürlü mü ve tarayıcıda localStorage'da duruyor mu? Form alan her endpoint antiforgery ile korunuyor ve UseAntiforgery() pipeline'da mı? Cookie ile çalışan JSON endpoint'lerinde header tabanlı doğrulama var mı?

Küçük Bir Deneme

★☆☆ ClockSkew satırını silip token ömrünü 5 saniyede bırak. Token'ın kaç saniye sonra reddedildiğini ölç ve varsayılan değerle karşılaştır.

★★☆ Lab'a ikinci bir kullanıcı ekle. /reviews/new'dan Ada'nın oturumuyla bir token al, sonra bu token'ı ikinci kullanıcının cookie'leriyle göndermeyi dene. Token'ın kullanıcıya bağlı olduğunu cevap koduyla doğrula.

★★★ /reviews endpoint'inin JSON kabul eden bir kopyasını yaz. Antiforgery'yi X-XSRF-TOKEN header'ıyla doğrulayacak şekilde ayarla, XSRF-TOKEN cookie'sini dönen bir endpoint ekle ve header olmadan gelen isteğin reddedildiğini curl ile göster.

Kimliği güvenle taşımayı gördük. Bir sonraki yazıda A08 Software or Data Integrity Failures'a geçiyoruz: güvenmediğin bir kaynaktan gelen verinin ve kodun bütünlüğü, insecure deserialization'ın neden tehlikeli olduğu, BinaryFormatter'ın .NET 9'daki sonu ve DTO'larla mass assignment'ı kapatmak.