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

Authentication ve Authorization'ın Haritası: Credential, Session, Token, Claim ve ASP.NET Core'un Authentication Handler Modeli

Mert Özen 8 Kas 2026 11 dk 5 görüntülenme
Authentication ve Authorization'ın Haritası: Credential, Session, Token, Claim ve ASP.NET Core'un Authentication Handler Modeli

Authentication kim olduğunu, authorization ne yapabileceğini bulur; ASP.NET Core'da ilki isimli scheme'ler ve handler'larla HttpContext.User'ı doldurur, ikincisi policy'ye bakıp challenge (401) ya da forbid (403) çağırır. Credential, session, token ve claim kavramlarını adlandırıyor, policy scheme ile cookie ve JWT'yi birleştiriyor ve .NET 10'da Results.Ok dönen endpoint'in neden hâlâ 302 verdiğini gösteriyoruz.

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

Önce Tahmin Et

Bookstore API'de (Application Programming Interface) iki endpoint var; ikisi de yalnız Admin rolüne açık ve ikisi de aynı JSON (JavaScript Object Notation) cevabını dönüyor. Tek fark, birinin TypedResults.Ok(...), diğerinin Results.Ok(...) kullanması. Customer rolündeki Ada, cookie'siyle ikisine de istek atıyor. .NET 10'da ikisi de 403 mü döner?

$ curl -i -b ada.txt http://localhost:19110/admin/sales-report
HTTP/1.1 403 Forbidden
Location: http://localhost:19110/Account/AccessDenied?ReturnUrl=%2Fadmin%2Fsales-report

$ curl -i -b ada.txt http://localhost:19110/admin/sales-report-legacy
HTTP/1.1 302 Found
Location: http://localhost:19110/Account/AccessDenied?ReturnUrl=%2Fadmin%2Fsales-report-legacy

Hayır. Aynı kullanıcı, aynı policy, aynı cevap; ama biri 403, diğeri var olmayan bir sayfaya redirect. Bu farkın nereden geldiğini anlamak için ASP.NET Core'un authentication modelini parça parça açmak gerekiyor. Bu yazı serinin haritası: kavramları adlandırıyoruz, Bookstore lab'ını kuruyoruz ve bir request'in "sen kimsin?" sorusundan "bunu yapabilir misin?" sorusuna nasıl geçtiğini log satırlarıyla izliyoruz.

İki Ayrı Soru: Kimsin ve Ne Yapabilirsin?

Authentication (kimlik doğrulama) "bu isteği gönderen kim?" sorusunun cevabıdır. Authorization (yetkilendirme) ise "bu kimlik, bu kaynak üzerinde bu işi yapabilir mi?" sorusunun. İkisi aynı pipeline'da art arda çalıştığı için sık karıştırılır, ama cevapları farklı yerlerden gelir. Authentication cevabını bir credential'dan çıkarır; authorization cevabını policy'den, rollerden, kaynağın sahibinden.

Credential, kullanıcının kim olduğunu kanıtlamak için sunduğu şeydir: parola, authenticator uygulamasındaki tek kullanımlık kod, cihazdaki bir private key. NIST'in (National Institute of Standards and Technology) dijital kimlik rehberi SP (Special Publication) 800-63B-4 bunları üç factor türüne ayırır: bildiğin bir şey, yani parola ya da PIN (Personal Identification Number); sahip olduğun bir şey, yani telefon ya da güvenlik anahtarı; ve olduğun bir şey, yani parmak izi ya da yüz. MFA (Multi-Factor Authentication) iki farklı türü birleştirmek demektir; iki parola sormak MFA değildir. Serinin 5. yazısında TOTP (Time-based One-Time Password) ile, 6. ve 7. yazılarda passkey'lerle bu konuya derinlemesine gireceğiz.

Credential bir kez doğrulanır. Sonraki her istekte kullanıcıdan parola istenmez; bunun yerine sunucu ona bir kanıt verir ve istemci bu kanıtı her istekte geri getirir. Sorunun ikinci yarısı bu kanıtın biçimi.

Session mı, Token mı?

Session-based modelde sunucu girişten sonra bir session kurar ve tarayıcıya bir cookie verir. Cookie ya session'ın kimliğini taşır (asıl veri sunucuda durur) ya da ASP.NET Core'daki gibi şifrelenmiş ve imzalanmış bir ticket'ı. Tarayıcı cookie'yi her istekte kendiliğinden ekler. Token-based modelde istemci bir access token alır ve onu Authorization: Bearer ... header'ına kendisi koyar. Token çoğunlukla JWT'dir (JSON Web Token): içinde claim'ler ve imza vardır, API onu kimseye sormadan doğrulayabilir.

İki modelin maliyeti farklı yerlerde. Cookie tarayıcıyla doğal çalışır, JavaScript'ten saklanabilir (HttpOnly), ama tarayıcı onu otomatik eklediği için CSRF (Cross-Site Request Forgery) riskini getirir. Token tarayıcı dışındaki istemcilerle (mobil, başka bir servis) kolay çalışır, ama iptal etmek zordur ve tarayıcıda saklanırsa XSS (Cross-Site Scripting) ile çalınabilir. Cookie flag'leri, JWT doğrulamanın temeli ve CSRF, Web Güvenliği (OWASP Top 10) serisinin 13. yazısında anlatıldı; burada tekrar etmiyorum.

Kimliği kim üretiyor sorusu da üç rolü ortaya çıkarıyor. IdP (Identity Provider, kimlik sağlayıcı) kullanıcıyı doğrulayan taraftır: Keycloak, Microsoft Entra ID ya da kendi ASP.NET Core Identity uygulaman. RP (Relying Party) IdP'nin verdiği kimliğe güvenen uygulamadır; OIDC (OpenID Connect) dünyasında "client" diye de geçer. Resource server ise access token'ı kabul edip veriyi koruyan API'dir. Küçük bir uygulamada üçü tek process'te olabilir; serinin ikinci yarısında OAuth 2.0 ve OIDC ile birbirinden ayrılacaklar.

Seri boyunca aynı örneği büyüteceğiz: Bookstore. ASP.NET Core 10 ile yazılmış bir API ve Angular 22 ile yazılmış bir web istemcisi. Test kullanıcıları ada@bookstore.test (Customer), grace@bookstore.test (Admin) ve alan@bookstore.test (Support). Bu ilk yazıda kullanıcı listesi koda gömülü; gerçek parola doğrulaması 3. yazıda ASP.NET Core Identity ile geliyor.

ASP.NET Core'un Modeli: Scheme ve Handler

ASP.NET Core authentication'ı tek bir mekanizma olarak değil, isimli scheme'ler olarak modeller. Scheme, bir handler ile onun options'ının birleşimine verilen addır. Lab'ın authentication configuration'ı şu:

builder.Services.AddAuthentication(options =>
    {
        options.DefaultScheme = "CookieOrBearer";
        options.DefaultChallengeScheme = "CookieOrBearer";
    })
    .AddPolicyScheme("CookieOrBearer", "Cookie or bearer token", options =>
    {
        // Bearer header'ı varsa JWT handler'ına, yoksa cookie handler'ına yönlendir
        options.ForwardDefaultSelector = context =>
            context.Request.Headers.Authorization.ToString().StartsWith("Bearer ", StringComparison.Ordinal)
                ? JwtBearerDefaults.AuthenticationScheme
                : CookieAuthenticationDefaults.AuthenticationScheme;
    })
    .AddCookie(options =>
    {
        options.Cookie.Name = "bookstore.auth";
        options.ExpireTimeSpan = TimeSpan.FromHours(8);
    })
    .AddJwtBearer(options =>
    {
        options.MapInboundClaims = false;
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidIssuer = Issuer,
            ValidAudience = Audience,
            IssuerSigningKey = signingKey,
            ValidAlgorithms = [SecurityAlgorithms.HmacSha256],
            NameClaimType = "name",
            RoleClaimType = "role"
        };
    });

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("Admin", policy => policy.RequireRole("Admin"));

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

Burada üç scheme var: Cookies, Bearer ve ikisinin önünde duran CookieOrBearer. AddCookie ve AddJwtBearer aslında AuthenticationBuilder.AddScheme'in kısayolları; her biri bir isim, bir handler type'ı (CookieAuthenticationHandler, JwtBearerHandler) ve bir options instance'ı kaydeder. Handler request başına oluşturulur ve IAuthenticationHandler'ı implement eder. Kendi handler'ını yazman gerekirse AuthenticationHandler<TOptions>'tan türetip HandleAuthenticateAsync'i override edersin; OAuth ve OIDC gibi uzak bir sunucuya gidip callback bekleyen handler'lar ise RemoteAuthenticationHandler<TOptions>'tan türer.

UseAuthentication() middleware'i default authenticate scheme'ini çalıştırır ve sonucu HttpContext.User'a yazar. Kimlik bulunamazsa request yine devam eder; User anonim bir principal olur. Engelleme işi authentication'ın değil, UseAuthorization()'ın.

01-request-journey-tr.png

ClaimsPrincipal, Claim ve AuthenticationProperties

Handler'ın ürettiği şey bir AuthenticationTicket: içinde bir ClaimsPrincipal ve bir AuthenticationProperties. Principal "kim" sorusunun cevabıdır; bir ya da birden çok ClaimsIdentity taşır, her identity de claim listesi taşır. Claim, bir issuer'ın kullanıcı hakkında söylediği tip-değer çiftidir: sub, email, role. Properties ise oturumun kendisi hakkındaki bilgidir: ne zaman verildi, ne zaman bitiyor, kalıcı mı. Login endpoint'i ikisini birlikte kuruyor:

app.MapPost("/login", async (LoginRequest request, HttpContext context) =>
{
    // Lab'a özel sabit kullanıcı listesi; gerçek parola kontrolü 3. yazıda Identity ile geliyor
    string[] roles = request.Email switch
    {
        "grace@bookstore.test" => ["Admin"],
        "ada@bookstore.test" => ["Customer"],
        _ => []
    };
    if (roles.Length == 0)
    {
        return Results.Unauthorized();
    }

    List<Claim> claims = [new("sub", request.Email), new("name", request.Email)];
    claims.AddRange(roles.Select(role => new Claim("role", role)));
    var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme, "name", "role");

    var properties = new AuthenticationProperties { IsPersistent = request.RememberMe };
    properties.Items["login_source"] = "password-form";

    await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity), properties);
    return Results.Ok(new { message = "Signed in" });
});

ClaimsIdentity constructor'ındaki ikinci argüman authentication type'tır; boş bırakırsan IsAuthenticated false döner ve kullanıcı claim'leri olduğu hâlde anonim sayılır. Üçüncü ve dördüncü argümanlar Identity.Name ve IsInRole'ün hangi claim tipine bakacağını söyler. /me endpoint'i hem principal'ı hem de cookie ticket'ının properties'ini döndürüyor:

$ curl -s -c ada.txt -X POST http://localhost:19110/login -H "Content-Type: application/json" -d '{"email":"ada@bookstore.test"}'
{"message":"Signed in"}
$ curl -s -b ada.txt http://localhost:19110/me
{"name":"ada@bookstore.test","authenticationType":"Cookies","roles":["Customer"],"cookieTicket":{"isPersistent":false,"issuedUtc":"2026-11-08T05:20:26+00:00","expiresUtc":"2026-11-08T13:20:26+00:00","loginSource":"password-form"}}

expiresUtc tam 8 saat sonrası; ExpireTimeSpan'den geliyor. isPersistent false olduğu için cookie'de expires yok, tarayıcı kapanınca silinir. Items sözlüğüne yazdığım login_source da ticket'la birlikte şifrelenip cookie'ye girdi ve geri geldi. Bu ömür ayarlarının ve ticket'ın içinin ayrıntısı bir sonraki yazının konusu.

Beş Eylem: Authenticate, Challenge, Forbid, SignIn, SignOut

Her handler aynı küçük sözleşmeye uyar. AuthenticateAsync request'ten kimlik çıkarır ve AuthenticateResult döner: başarılı (ticket'la), başarısız (hata mesajıyla) ya da sonuç yok. ChallengeAsync kimliği olmayan kullanıcıya "kendini tanıt" der; cookie handler login sayfasına yönlendirir, JWT handler WWW-Authenticate: Bearer ile 401 döner. ForbidAsync kimliği olan ama yetkisi olmayan kullanıcıya "seni tanıyorum, ama hayır" der. SignInAsync ve SignOutAsync ise yalnız oturum tutabilen handler'larda vardır (IAuthenticationSignInHandler); cookie handler'da var, JWT handler'da yok, çünkü JWT handler token üretmez, yalnız doğrular.

Bu eylemleri kimin çağırdığını görmek için Microsoft.AspNetCore.Authentication ve Microsoft.AspNetCore.Authorization kategorilerini Debug seviyesine aldım. Ada'nın /admin/sales-report isteği:

dbug: Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler[8]
      AuthenticationScheme: Cookies was successfully authenticated.
info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2]
      Authorization failed. These requirements were not met:
      RolesAuthorizationRequirement:User.IsInRole must be true for one of the following roles: (Admin)
info: Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler[13]
      AuthenticationScheme: Cookies was forbidden.

Sıra figürdeki gibi: önce authentication başarılı, sonra authorization başarısız, ardından authorization middleware'i ForbidAsync'i çağırıyor. Cookie olmadan aynı endpoint'e gidince son satır Cookies was challenged. oluyor. Kural basit: kimlik yoksa challenge, kimlik var ama policy tutmuyorsa forbid. 401 ile 403 arasındaki fark da tam bu; ASP.NET Core Web API serisinin 23. yazısında (Authorization: Roller, Policy'ler ve Resource-Based Yetkilendirme) status code tarafından anlatılmıştı.

Birden Çok Scheme: Default Seçimi ve Policy Scheme

Tek bir scheme kaydedersen .NET 7'den beri o scheme otomatik olarak default olur. İki ya da daha fazla scheme varsa ve default belirtmezsen, scheme adı vermeyen ilk [Authorize] bir InvalidOperationException fırlatır. AuthenticationOptions'ta beş ayrı default var: DefaultAuthenticateScheme, DefaultChallengeScheme, DefaultForbidScheme, DefaultSignInScheme, DefaultSignOutScheme. Hiçbiri ayarlanmamışsa hepsi DefaultScheme'e düşer. ASP.NET Core Identity de kendi default'larını yazar; Identity'den sonra AddAuthentication("Bearer") çağırmak yalnız DefaultScheme'i değiştirir, Identity'nin ayarladığı authenticate ve challenge default'ları yerinde kalır. "JWT ekledim ama hâlâ login sayfasına yönlendiriyor" şikâyetinin çoğu buradan çıkar.

Aynı API'yi hem tarayıcıdaki cookie'yle hem de bir mobil istemcinin bearer token'ıyla çağırmak istiyorsan policy scheme işe yarar. AddPolicyScheme kendisi kimlik doğrulamaz; ForwardDefaultSelector ile request'e bakar ve beş eylemin hepsini seçtiği scheme'e iletir. Grace bir token alıp aynı endpoint'lere gidiyor:

$ curl -s -H "Authorization: Bearer $T" http://localhost:19110/me
{"name":"grace@bookstore.test","authenticationType":"AuthenticationTypes.Federation","roles":["Admin"],"cookieTicket":null}
$ curl -i -H "Authorization: Bearer $T" http://localhost:19110/admin/sales-report
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"totalOrders":42}

authenticationType artık Cookies değil, JWT handler'ın ürettiği identity'nin tipi. Alternatif olarak policy'ye AddAuthenticationSchemes("Cookies", "Bearer") yazabilirsin; o zaman authorization her iki scheme'i de çalıştırır ve sonuçları birleştirir. Fark şu: policy scheme request başına tek handler çalıştırır, policy üzerindeki scheme listesi ise her istekte listedeki tüm handler'ları dener ve challenge'ı da hepsine gönderir.

Baştaki Sorunun Cevabı: 401/403 mü, 302 mi?

.NET 10'dan önce cookie handler her challenge'da login sayfasına, her forbid'de AccessDenied sayfasına 302 ile yönlendiriyordu. Bir API için bu anlamsız: fetch redirect'i sessizce takip eder ve Angular tarafı 401 yerine bir HTML (HyperText Markup Language) sayfası alır. .NET 10 bunu değiştirdi. Endpoint'te IApiEndpointMetadata varsa cookie handler redirect yerine 401 ya da 403 döner. Bu metadata kendiliğinden eklenir: [ApiController] controller'larına, JSON body okuyan ya da JSON yazan minimal API endpoint'lerine, TypedResults dönüş tipli endpoint'lere ve SignalR hub'larına.

Baştaki farkın sebebi bu listede saklı. TypedResults.Ok(new SalesReport(42)) dönen lambda'nın dönüş tipi Ok<SalesReport>; framework derleme anında JSON yazacağını biliyor ve metadata'yı ekliyor. Results.Ok(...) ise IResult döndürüyor; ne yazacağı çalışma anında belli oluyor, bu yüzden endpoint "API" sayılmıyor ve eski davranış devam ediyor. Cookie'siz istek de aynı ayrımı gösteriyor:

$ curl -i http://localhost:19110/admin/sales-report
HTTP/1.1 401 Unauthorized
Location: http://localhost:19110/Account/Login?ReturnUrl=%2Fadmin%2Fsales-report

Status code 401, ama Location header'ı hâlâ yazılıyor; istemci onu yok sayar. Bu davranışı hiçbir endpoint'e bırakmamak istiyorsan cookie options'ında Events.OnRedirectToLogin ve Events.OnRedirectToAccessDenied'ı override edip doğrudan context.Response.StatusCode'u yazabilirsin. Microsoft dokümanı tersini, yani her zaman redirect'i geri getirmeyi de aynı iki event ile gösteriyor.

Üretimde Dikkat

Authentication sorunlarını tahminle değil logla çöz. Microsoft.AspNetCore.Authentication kategorisini production'da sürekli Debug'da tutma, ama sorun anında açabileceğin şekilde configuration'dan yönet. JWT tarafında hata mesajları yanıltıcı olabilir: lab'da imzanın son karakterini değiştirdiğimde istemciye giden cevap buydu:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The signature key was not found"

Log ise IDX10517: Signature validation failed. The token's kid is missing. diyordu. Sorun anahtarın bulunamaması değil, imzanın tutmaması. Token'da kid (key ID) header'ı olmadığı için library elindeki tek anahtarı denedi, imza tutmadı ve hatayı "anahtar bulunamadı" diye raporladı. JWT'nin kid ve JWKS (JSON Web Key Set) tarafı 8. yazının konusu.

.NET 10 authentication ve authorization için metrikler de getirdi: authenticated request süresi, challenge, forbid, sign in ve sign out sayıları ve authorization gerektiren request sayısı. Forbid sayısındaki ani bir artış, bir deploy'da policy'nin yanlış değiştiğinin ya da birinin endpoint taradığının ilk işareti olabilir. Bunları OpenTelemetry ile topluyorsan ayrı bir dashboard'a koymaya değer.

Son olarak ölçek: cookie ticket'ı her istekte çözülür, JWT her istekte imza doğrulamasından geçer. İkisi de mikrosaniyeler mertebesinde ve veritabanına gitmez. Veritabanına gidildiği an (server-side session, security stamp kontrolü, token iptal listesi) maliyet değişir; bunu 2. ve 9. yazılarda ölçeceğiz.

Sık Düşülen Tuzaklar

Birincisi, authentication sonucunu authorization sanmak. UseAuthentication() kimseyi engellemez; endpoint'e RequireAuthorization() ya da bir fallback policy koymazsan anonim kullanıcı da içeri girer. Varsayılan olarak herkesi kimlik doğrulamaya zorlayan fallback policy, Web Güvenliği serisinin 3. yazısında anlatıldı.

İkincisi, 401 ile 403'ü karıştırmak. Kimliği olan ama yetkisi olmayan kullanıcıya 401 dönersen istemci onu login'e gönderir; kullanıcı tekrar giriş yapar, yine 401 alır ve döngüye girer. 401 "kim olduğunu bilmiyorum", 403 "biliyorum ama izin yok" demek.

Üçüncüsü, UseAuthentication() ile UseAuthorization()'ın sırasını değiştirmek ya da authentication'ı unutmak. Authorization önce çalışırsa User her zaman anonimdir ve her korumalı endpoint 401 döner.

Dördüncüsü, ClaimsIdentity'yi authentication type vermeden kurmak. Claim'ler orada olduğu hâlde IsAuthenticated false olur ve sorun saatlerce claim'lerde aranır.

Beşincisi, .NET 10'a geçince bütün API endpoint'lerinin 401/403 döneceğini varsaymak. Gördüğün gibi Results.Ok dönen minimal API endpoint'leri ve string dönen endpoint'ler hâlâ redirect edebilir; serinin 7. yazısında hazır bir JSON string'ini TypedResults.Content ile döndüren bir endpoint'in de 302 verdiğini göreceğiz. Geçişten sonra her korumalı endpoint'i kimliksiz ve yetkisiz birer istekle test et.

Küçük Bir Deneme

★☆☆ Lab'da DefaultScheme ve DefaultChallengeScheme satırlarını silip yalnız AddCookie ve AddJwtBearer'ı bırak. /me'ye istek at ve fırlayan exception'ın mesajını oku. Sonra AddJwtBearer'ı da silip tek scheme'in otomatik default olduğunu doğrula.

★★☆ /admin/sales-report-legacy endpoint'ini değiştirmeden, cookie options'ındaki iki event ile tüm API isteklerinde 302 yerine 401/403 dönmesini sağla. Bir endpoint'in API olup olmadığına karar vermek için context.Request.Path yerine endpoint metadata'sını kullanmayı dene.

★★★ AuthenticationHandler<AuthenticationSchemeOptions>'tan türeyen ve yalnız Testing environment'ında kayıtlı bir TestAuthHandler yaz: X-Test-User header'ındaki e-postayla principal üretsin. Integration testlerinde neden işe yaradığını ve production'a sızarsa neden tam bir authentication bypass olacağını iki cümleyle yaz; registration'ı environment kontrolüyle nasıl koruduğunu göster.

Haritayı çizdik: credential, session, token, claim, scheme ve handler. Bir sonraki yazıda cookie authentication'ın içine giriyoruz: ticket'ta ne var, sliding expiration nasıl çalışıyor, absolute timeout'u nasıl eklersin ve bir kullanıcıyı "tüm cihazlardan çıkış" ile gerçekten nasıl dışarı atarsın.