Mert Özen Her Satırda Daha İleri
Backend .NET Core Web API — #22

ASP.NET Core Web API'de JWT ile Authentication: Token Üretmek ve Doğrulamak

Mert Özen 4 Kas 2026 10 dk 2 görüntülenme
ASP.NET Core Web API'de JWT ile Authentication: Token Üretmek ve Doğrulamak

Authentication ile authorization farkından JWT'nin anatomisine, AddJwtBearer kurulumundan JsonWebTokenHandler ile token üretmeye kadar login akışını kuruyoruz. ClockSkew, claim eşleme ve refresh token fikri de var.

Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.

Önce Tahmin Et

Bir önceki yazının sonunda bir açık bırakmıştık: ürün görselini isteyen herkes değiştirebiliyordu, çünkü API isteği kimin attığını bilmiyordu. Bu yazıda o soruyu cevaplıyoruz. Kullanıcı e-posta ve şifresiyle giriş yapacak, API ona imzalı bir JWT verecek, kullanıcı da sonraki her istekte bu token'ı gösterecek.

Önce bir tahmin. Elinde ömrü 15 değil, deneme için 1 dakikaya indirilmiş bir token var. Token'ın exp değeri 21:22:33'ü gösteriyor. Saat 21:22:51'de, yani token'ın süresi 18 saniye önce dolmuşken korunan bir endpoint'e istek atıyorsun. 200 mü alırsın, 401 mi? Cevap "Login Endpoint'i ve 401'in Dili" bölümünde.

Authentication ve Authorization Farkı

İki kelime birbirine çok benzediği için sık karıştırılır, ama farklı soruları cevaplarlar. Authentication "sen kimsin?" sorusudur. Authorization ise "kim olduğunu biliyorum, peki bunu yapmaya iznin var mı?" sorusudur.

Havalimanını düşün. Pasaport kontrolünde memur yüzüne ve pasaportuna bakar; bu authentication. Biniş kapısındaki görevli ise biniş kartına bakıp bu uçağa binip binemeyeceğine karar verir; bu da authorization. Pasaportun geçerli olabilir ama başka bir uçağın kapısından geçemezsin.

HTTP'de bu ayrım status code'lara yansır. Kim olduğunu kanıtlamadıysan 401 Unauthorized alırsın (adı yanıltıcı, aslında "unauthenticated" demek). Kim olduğun belli ama yetkin yoksa 403 Forbidden alırsın. Bu yazı birinci kapıyla ilgili; roller, policy'ler ve 403 bir sonraki yazının konusu.

JWT'nin Anatomisi

Login olduğunda API'den dönecek token'ın kendisi şuna benzer:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5jb20iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwianRpIjoiNmM1ZGFlMzctMTQyNi00NTczLTgwY2YtNDU5MDgwM2Q3ZGUwIiwiYXVkIjoiUHJvZHVjdENhdGFsb2cuQ2xpZW50cyIsImV4cCI6MTc5Mzc0MTc3MiwiaXNzIjoiUHJvZHVjdENhdGFsb2cuQXBpIiwiaWF0IjoxNzkzNzQwODcyLCJuYmYiOjE3OTM3NDA4NzJ9.1NlwHITl09ZAkKYzMIwpaSD7VK0wQ3-F02vuVU8GDJw

Noktalarla ayrılmış üç parça var: header, payload ve signature. İlk ikisi şifreli değil, sadece base64url ile kodlanmış JSON. Bu token'ın ilk iki parçasını çözdüğümde şunu gördüm:

{
  "alg": "HS256",
  "typ": "JWT"
}
{
  "sub": "1",
  "email": "ada@example.com",
  "name": "Ada Lovelace",
  "jti": "6c5dae37-1426-4573-80cf-4590803d7de0",
  "aud": "ProductCatalog.Clients",
  "exp": 1793741772,
  "iss": "ProductCatalog.Api",
  "iat": 1793740872,
  "nbf": 1793740872
}

Header, imzanın HMAC-SHA256 ile atıldığını söylüyor. Payload'daki her alan bir claim. sub kullanıcının kimliği, iss token'ı kimin ürettiği, aud token'ın kimin için üretildiği. exp, iat ve nbf Unix zaman damgası: sırasıyla bitiş, üretilme ve "bundan önce geçersiz" anları. jti ise token'ın benzersiz kimliği.

Üçüncü parça olan imza, ilk iki parçanın gizli bir anahtarla hesaplanmış özeti. Token'ı herkes okuyabilir, ama anahtarı bilmeyen kimse içeriğini değiştirip geçerli bir imza üretemez. Bunu noterden onaylı bir belgeye benzetebilirsin: metni herkes okur, ama mühür sahte olursa belge geçmez. Bu yüzden payload'a şifre, kart numarası gibi gizli bir şey asla koymazsın.

Paket ve Program.cs

JWT doğrulaması shared framework'te değil, ayrı bir NuGet paketinde. Paketin major sürümü projenin hedeflediği .NET sürümüyle eşleşmeli:

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 6.0.36

.NET 6 serisinin son sürümü 6.0.36; .NET 10 projesinde aynı paketin 10.0.14 sürümünü kullanırsın. --version vermeden .NET 6 projesine eklemeye çalışırsan NuGet en yeni sürümü seçer ve şu hatayla durur:

error: NU1202: Package Microsoft.AspNetCore.Authentication.JwtBearer 10.0.14 is not compatible with net6.0 (.NETCoreApp,Version=v6.0). Package Microsoft.AspNetCore.Authentication.JwtBearer 10.0.14 supports: net10.0 (.NETCoreApp,Version=v10.0)

Ayarları configuration'da tutup Options pattern ile okuyoruz:

namespace ProductCatalog.Auth;

public class JwtOptions
{
    public const string SectionName = "Jwt";

    public string Issuer { get; set; } = string.Empty;
    public string Audience { get; set; } = string.Empty;
    public string SigningKey { get; set; } = string.Empty;
    public int AccessTokenMinutes { get; set; } = 15;
}

appsettings.json içine Issuer, Audience ve AccessTokenMinutes değerlerini koyuyoruz. SigningKey ise repository'ye girmemeli; geliştirme ortamında user secrets, production'da environment variable ya da bir secret store kullan. Ben bu örnekte sadece appsettings.Development.json içine geliştirmeye özel bir anahtar koydum. Şimdi Program.cs:

using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using ProductCatalog.Auth;

var builder = WebApplication.CreateBuilder(args);

var jwtSection = builder.Configuration.GetSection(JwtOptions.SectionName);
builder.Services.Configure<JwtOptions>(jwtSection);
var jwt = jwtSection.Get<JwtOptions>()!;

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // Gelen token'ı hangi kurallarla doğrulayacağız
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = jwt.Issuer,
            ValidateAudience = true,
            ValidAudience = jwt.Audience,
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SigningKey)),
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromSeconds(30)
        };
    });

builder.Services.AddAuthorization();

builder.Services.AddSingleton<UserStore>();
builder.Services.AddSingleton<ITokenService, TokenService>();
builder.Services.AddControllers();

var app = builder.Build();

// Sıra önemli: önce kim olduğunu bul, sonra iznine bak
app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();

AddAuthentication varsayılan scheme'i Bearer olarak ayarlıyor, AddJwtBearer de bu scheme'in handler'ını kaydediyor. TokenValidationParameters içindeki her Validate... satırı bir kontrol: token'ı bizim API mi üretti, bizim istemcilerimiz için mi üretildi, imza doğru mu, süresi dolmuş mu? UseAuthentication middleware'i her istekte Authorization header'ındaki token'ı okuyup doğrular ve sonucu HttpContext.User'a yazar. Middleware sırasını pipeline yazısında konuşmuştuk; burada da önce authentication, sonra authorization gelir.

Token Üretmek: JwtSecurityTokenHandler mı, JsonWebTokenHandler mı?

İnternette bulacağın .NET 6 örneklerinin çoğu token'ı şöyle üretir:

        var token = new JwtSecurityToken(
            issuer: _options.Issuer,
            audience: _options.Audience,
            claims: claims,
            expires: expiresAt,
            signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

        return new TokenResponse(new JwtSecurityTokenHandler().WriteToken(token), expiresAt);

Bu kod hâlâ derleniyor ve çalışıyor; hem .NET 6'da hem .NET 10'da denedim. Ama System.IdentityModel.Tokens.Jwt paketinin NuGet açıklaması artık açık konuşuyor: IdentityModel 7.x'ten itibaren bu paket legacy sayılıyor ve yerine Microsoft.IdentityModel.JsonWebTokens öneriliyor. Doğrulama tarafı da bu yöne döndü. .NET 6'daki JwtBearer paketi token'ı SecurityTokenValidators listesindeki JwtSecurityTokenHandler ile doğruluyor. .NET 8 ve sonrasında ise varsayılan TokenHandlers listesinde JsonWebTokenHandler var; eski davranış UseSecurityTokenValidators ayarıyla geri açılabiliyor.

İyi haber: JsonWebTokenHandler, JwtBearer paketiyle birlikte zaten projene geliyor. .NET 6'da 6.35.0, .NET 10'da 8.19.2 sürümüyle. Üstelik aynı kod ikisinde de derleniyor. Bu yüzden yeni kodda onu kullanıyoruz:

using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

namespace ProductCatalog.Auth;

public record TokenResponse(string AccessToken, DateTime ExpiresAt);

public interface ITokenService
{
    TokenResponse CreateAccessToken(AppUser user);
}

public class TokenService : ITokenService
{
    private readonly JwtOptions _options;

    public TokenService(IOptions<JwtOptions> options)
    {
        _options = options.Value;
    }

    public TokenResponse CreateAccessToken(AppUser user)
    {
        var expiresAt = DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes);
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));

        var descriptor = new SecurityTokenDescriptor
        {
            Issuer = _options.Issuer,
            Audience = _options.Audience,
            Expires = expiresAt,
            SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
            // Token'ın taşıyacağı claim'ler
            Subject = new ClaimsIdentity(new[]
            {
                new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
                new Claim(JwtRegisteredClaimNames.Email, user.Email),
                new Claim("name", user.DisplayName),
                new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
            })
        };

        var token = new JsonWebTokenHandler().CreateToken(descriptor);
        return new TokenResponse(token, expiresAt);
    }
}

SecurityTokenDescriptor token'ın tarifini veriyor; iat ve nbf alanlarını handler kendisi ekliyor. Yukarıda çözdüğümüz payload bu kodun çıktısıydı.

Login Endpoint'i ve 401'in Dili

Kullanıcılar için şimdilik bellekte duran küçük bir store kullanıyoruz. Gerçek projede bu veritabanında olur, ama bir kuralı burada bile çiğnemiyoruz: şifre düz metin olarak saklanmaz. Microsoft.AspNetCore.Identity namespace'indeki PasswordHasher<TUser> shared framework'ün parçası ve ek paket istemiyor:

    public AppUser? ValidateCredentials(string email, string password)
    {
        var user = _users.FirstOrDefault(u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase));
        if (user is null)
        {
            return null;
        }

        var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, password);
        return result == PasswordVerificationResult.Failed ? null : user;
    }

Controller tarafı:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Auth;

namespace ProductCatalog.Controllers;

public record LoginRequest(string Email, string Password);

[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly UserStore _users;
    private readonly ITokenService _tokens;

    public AuthController(UserStore users, ITokenService tokens)
    {
        _users = users;
        _tokens = tokens;
    }

    [HttpPost("login")]
    [AllowAnonymous]
    public ActionResult<TokenResponse> Login(LoginRequest request)
    {
        var user = _users.ValidateCredentials(request.Email, request.Password);
        if (user is null)
        {
            // Hangi bilginin yanlış olduğunu söylemiyoruz
            return Unauthorized();
        }

        return Ok(_tokens.CreateAccessToken(user));
    }

    [HttpGet("me")]
    [Authorize]
    public IActionResult Me()
    {
        var claims = User.Claims.Select(c => new { c.Type, c.Value });
        return Ok(claims);
    }
}

ProductsController'daki POST action'ına da [Authorize] ekledim; GET herkese açık kaldı. Önce token'sız bir ürün eklemeyi deneyelim, sonra login olalım:

curl -i -X POST http://localhost:5080/api/products -H "Content-Type: application/json" -d '{"id":3,"name":"USB-C Hub","price":900}'
HTTP/1.1 401 Unauthorized
Content-Length: 0
Server: Kestrel
WWW-Authenticate: Bearer
curl -X POST http://localhost:5080/api/auth/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"Pa55word!"}'
{"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5jb20iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwianRpIjoiNmM1ZGFlMzctMTQyNi00NTczLTgwY2YtNDU5MDgwM2Q3ZGUwIiwiYXVkIjoiUHJvZHVjdENhdGFsb2cuQ2xpZW50cyIsImV4cCI6MTc5Mzc0MTc3MiwiaXNzIjoiUHJvZHVjdENhdGFsb2cuQXBpIiwiaWF0IjoxNzkzNzQwODcyLCJuYmYiOjE3OTM3NDA4NzJ9.1NlwHITl09ZAkKYzMIwpaSD7VK0wQ3-F02vuVU8GDJw","expiresAt":"2026-11-03T21:36:12.501897Z"}

Aynı POST isteğini -H "Authorization: Bearer <token>" ile tekrarladığında 201 Created ve eklenen ürün dönüyor. Yanlış şifreyle login olmayı denersen Unauthorized() sonucunu [ApiController] ProblemDetails'e çeviriyor ve "title":"Unauthorized","status":401 içeren bir body alıyorsun.

401'in body'si boş ama WWW-Authenticate header'ı konuşkan. Token'ı hiç göndermezsen ya da Bearer önekini unutursan sadece Bearer yazar; handler ortada doğrulanacak bir token görmemiştir. İmzanın son karakterini değiştirdiğimde error="invalid_token", error_description="The signature key was not found" geldi. Süresi dolmuş token için de şu:

WWW-Authenticate: Bearer error="invalid_token", error_description="The token expired at '11/03/2026 21:22:33'"

Baştaki sorunun cevabı burada. exp 21:22:33'ü gösteren token'la 21:22:51'de attığım istek 200 döndü. Saat 21:23:08'de ise yukarıdaki 401'i aldım. Sebep ClockSkew: sunucuların saatleri arasındaki küçük farklar yüzünden token'lar reddedilmesin diye doğrulayıcı bitiş zamanına tolerans ekliyor. Biz 30 saniye verdik; varsayılanı 5 dakika. Yani ayarı hiç yapmasaydın 15 dakikalık token'ın fiilen 20 dakika yaşardı.

Claim'ler Neden İsim Değiştiriyor?

/api/auth/me endpoint'inin cevabına bak:

[{"type":"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier","value":"1"},{"type":"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress","value":"ada@example.com"},{"type":"name","value":"Ada Lovelace"},{"type":"jti","value":"6c5dae37-1426-4573-80cf-4590803d7de0"},{"type":"aud","value":"ProductCatalog.Clients"},{"type":"exp","value":"1793741772"},{"type":"iss","value":"ProductCatalog.Api"},{"type":"iat","value":"1793740872"},{"type":"nbf","value":"1793740872"}]

Token'a sub ve email koymuştuk, ama User içinde uzun XML namespace'li isimler var. JwtBearer, MapInboundClaims ayarı varsayılan olarak true olduğu için kısa JWT claim adlarını eski WS-Federation adlarına çeviriyor. Sonuç: User.FindFirst("sub") null döner, User.FindFirst(ClaimTypes.NameIdentifier) çalışır. Bu davranış .NET 10'da da aynı; sadece claim'lerin sırası farklı geliyor.

İki yol var. Ya bu eşlemeyi kabul edip kodda ClaimTypes sabitlerini kullanırsın, ya da eşlemeyi kapatırsın:

    .AddJwtBearer(options =>
    {
        options.MapInboundClaims = false;

Bu satırla me cevabında {"type":"sub","value":"1"},{"type":"email","value":"ada@example.com"} görürsün. Token'daki isimle koddaki ismin aynı olması bence daha az sürpriz demek. Hangisini seçersen seç, projede tek bir yol belirle.

Refresh Token Fikri

Access token'ı kısa tutmak güvenlik için iyi: çalınsa bile birkaç dakika sonra işe yaramaz. Ama kullanıcıyı her 15 dakikada bir şifre girmeye zorlayamazsın. Çözüm ikinci bir token: refresh token.

Refresh token bir JWT değil, tahmin edilemeyen rastgele bir değer. Login olduğunda access token'la birlikte verilir, sunucuda da kullanıcıyla ilişkilendirilip saklanır. Access token'ın süresi dolunca istemci refresh token'ı ayrı bir endpoint'e gönderir; sunucu onu kendi kaydıyla karşılaştırır, geçerliyse yeni bir access token ve yeni bir refresh token verir, eskisini de iptal eder. Buna rotation denir; çalınmış bir refresh token'ın ikinci kez kullanılması böylece fark edilir. Üretmek için birkaç satır yeter:

using System.Security.Cryptography;
using System.Text;

namespace ProductCatalog.Auth;

public static class RefreshTokenFactory
{
    // Kriptografik olarak güvenli 64 byte rastgele değer
    public static string Create() =>
        Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));

    // Veritabanına token'ın kendisini değil, hash'ini yaz
    public static string Hash(string refreshToken) =>
        Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken)));
}

Saklama, süre, iptal ve "tüm cihazlardan çıkış" gibi detaylar veritabanı tasarımına bağlı. Burada fikri bilmen yeterli; tam akışı Küçük Bir Deneme'de sana bırakıyorum.

Sık Düşülen Tuzaklar

Kısa imza anahtarı. 13 karakterlik my-secret-key ile token üretmeye çalıştığımda IDX10653: The encryption algorithm 'HS256' requires a key size of at least '128' bits aldım. 20 karakterlik bir anahtarda ise hata IDX10720 oldu: HS256 için anahtar 256 bit, yani en az 32 byte olmalı. Anahtarı rastgele üret, elle uydurma.

UseAuthentication'ı unutmak ya da UseAuthorization'dan sonraya koymak. .NET 6'da satırı sildiğimde geçerli token'la bile 401 aldım, çünkü User boş kalıyor. Aynı denemede .NET 10 200 döndü; yeni sürümlerde WebApplication eksik middleware'i kendisi ekliyor. Yine de satırı açıkça yaz. Sırayı ters çevirdiğimde ise iki sürümde de 401 aldım.

ValidateIssuer, ValidateAudience ya da ValidateLifetime değerlerini "çalışsın diye" false yapmak. Başka bir sistem için üretilmiş ya da günler önce süresi dolmuş token'lar da kabul edilir.

Payload'ı gizli sanmak. Herkes base64url'i çözebilir; token'a koyduğun her şeyi kullanıcı da okur.

Paket sürümünü karıştırmak. JwtBearer'ın major sürümü hedef framework'le aynı olmalı: .NET 6 için 6.0.x, .NET 10 için 10.0.x.

Küçük Bir Deneme

★☆☆ Jwt__AccessTokenMinutes=1 environment variable'ı ile uygulamayı başlat, bir token al ve süresi dolduktan sonra ClockSkew toleransının bitmesini izle. Sonra ClockSkew satırını sil ve aynı denemeyi tekrarla.

★★☆ MapInboundClaims = false yap ve me endpoint'ine kullanıcının sadece sub ve email değerlerini dönen bir cevap yazdır. Aynı kodun eşleme açıkken neden null döndüğünü gözlemle.

★★★ Refresh token akışını kur: login cevabına bir refresh token ekle, hash'ini kullanıcıyla birlikte sakla, POST api/auth/refresh endpoint'inde rotation uygula ve aynı refresh token ikinci kez gelirse reddet.

Artık API kimin konuştuğunu biliyor. Ama Ada'nın token'ı geçerli diye her şeyi yapabilmesi gerekmiyor; ürün silmek belki sadece yöneticilere açık olmalı. Bir sonraki yazıda authorization'a geçip roller, claim'ler ve policy'lerle kimin neye izinli olduğunu tanımlayacağız.