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

JWT Derinlemesine: JWS ve JWE, Algoritma Seçimi, kid, JWKS ve Signing Key Rotation

Mert Özen 8 Kas 2026 11 dk 4 görüntülenme
JWT Derinlemesine: JWS ve JWE, Algoritma Seçimi, kid, JWKS ve Signing Key Rotation

Kesintisiz signing key rotation üç adımdır: yeni key'i JWKS'te yayınla, API'lerin cache'i yenilenene kadar bekle (varsayılan 12 saat), sonra onunla imzala ve eski key'i token ömrü dolunca kaldır. JWS ve JWE farkını, ES256 seçimini, alg none ve algorithm confusion denemelerini, at+jwt ile typ doğrulamayı, kid ve JWKS'i, 8725bis taslağının uyarılarını ve claim şişmesini gerçek çıktılarla gösteriyoruz.

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

Önce Tahmin Et

Bookstore'un token servisi access token'ları ES256 ile imzalıyor ve public key'leri bir JWKS (JSON Web Key Set) endpoint'inde yayınlıyor. Signing key'i değiştirme zamanı geldi ve ekip doğru bildiği sırayı izliyor: önce yeni key'i JWKS'e ekle, sonra token'ları yeni key'le imzalamaya başla. API (Application Programming Interface) tarafı bu iki adım arasında hiçbir şey yapmıyor. Yeni key'le imzalanmış ilk token API'de geçer mi?

token A -> 200
after publish+switch: B #1 -> 401, B #2 -> 200, B #3 -> 200

İlki geçmiyor, ikincisi geçiyor. Yeni key yayınlanmıştı ama API'nin elindeki JWKS kopyası eskiydi; bilinmeyen kid görünce JWKS'i yenilemeyi planladı, o isteği ise reddetti. Yüz instance'lı bir API'de bu, rotation anında her instance'tan en az bir başarısız istek demek. Bu yazıda JWT'nin (JSON Web Token) içini açıyoruz: JWS (JSON Web Signature) ve JWE (JSON Web Encryption), algoritma seçimi, typ, kid, JWKS ve kesintisiz key rotation. JWT doğrulamanın temeli (issuer, audience, süre, ValidAlgorithms, ClockSkew) Web Güvenliği (OWASP Top 10) serisinin 13. yazısında anlatıldı; burada onun üzerine çıkıyoruz.

Bir Access Token'ın Anatomisi

Lab'daki token servisi şu token'ı üretiyor:

app.MapPost("/token", (TokenRequest request, SigningKeyRing ring) =>
{
    var handler = new JsonWebTokenHandler();
    string token = handler.CreateToken(new SecurityTokenDescriptor
    {
        TokenType = request.Type ?? "at+jwt",
        Issuer = issuer,
        Audience = request.Audience ?? "bookstore-api",
        IssuedAt = DateTime.UtcNow,
        NotBefore = DateTime.UtcNow,
        Expires = DateTime.UtcNow.AddMinutes(5),
        Claims = new Dictionary<string, object>
        {
            ["sub"] = request.Subject,
            ["client_id"] = "bookstore-web",
            ["scope"] = "books.read orders.read",
            ["jti"] = Guid.NewGuid().ToString("N")
        },
        SigningCredentials = new SigningCredentials(ring.Active, SecurityAlgorithms.EcdsaSha256)
    });
    return TypedResults.Ok(new { access_token = token, kid = ring.Active.KeyId });
});

Token'ın üç parçasını çözünce:

length 458
{'alg': 'ES256', 'kid': 'bookstore-2026-11-a', 'typ': 'at+jwt'}
{'aud': 'bookstore-api', 'iss': 'http://localhost:19180', 'exp': 1794118708, 'iat': 1794118408, 'nbf': 1794118408, 'sub': 'ada', 'client_id': 'bookstore-web', 'scope': 'books.read orders.read', 'jti': '99b58766650a4d05931695567d427cf9'}
signature bytes 64

Claim'lerin seçimi rastgele değil. RFC (Request for Comments) 9068, OAuth 2.0 access token'ları için bir JWT profili tanımlar: header'da typ: at+jwt, payload'da iss, exp, aud, sub, client_id, iat ve jti zorunlu. Bu profil, bir ID token ile access token'ın birbirine karıştırılmasını zorlaştırır; birazdan bunun neden önemli olduğunu göreceğiz. Lab'da issuer http://localhost:19180, çünkü API metadata'yı aynı adresten okuyor; gerçek kurulumda https://auth.bookstore.test gibi bir HTTPS (HTTP Secure) adresi olur.

Token'ı JsonWebTokenHandler üretiyor ve doğruluyor. .NET 8'den beri AddJwtBearer varsayılan olarak bu handler'ı kullanıyor; eski JwtSecurityTokenHandler'a göre daha hızlı ve bellek dostu. Eski handler'a bağlı kod (SecurityTokenValidators listesine eklenmiş custom validator'lar, JwtSecurityToken cast'leri) .NET 8 geçişinde sessizce devre dışı kalabilir; bunun ayrıntısı 18. yazıda.

JWS mi, JWE mi?

Yukarıdaki token bir JWS: imzalı ama şifresiz. Payload'ı herkes okuyabilir. Çoğu access token için bu yeterli, çünkü içinde sır yok ve token zaten TLS (Transport Layer Security) üzerinden taşınıyor. Token'ın taşıdığı veri istemciden ya da aradaki bir sistemden gizlenmeliyse JWE kullanılır. JsonWebTokenHandler nested JWT üretebiliyor: önce imzala, sonra API'nin public key'iyle şifrele:

var descriptor = new SecurityTokenDescriptor
{
    TokenType = "at+jwt",
    Issuer = "https://auth.bookstore.test",
    Audience = "bookstore-api",
    Expires = DateTime.UtcNow.AddMinutes(5),
    Claims = new Dictionary<string, object> { ["sub"] = "ada", ["email"] = "ada@bookstore.test" },
    SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.EcdsaSha256),
    // Nested JWT: önce imzalanır (JWS), sonra API'nin public key'i için şifrelenir (JWE)
    EncryptingCredentials = new EncryptingCredentials(encryptionKey, SecurityAlgorithms.RsaOAEP, SecurityAlgorithms.Aes256CbcHmacSha512)
};
string jwe = handler.CreateToken(descriptor);
JWE parts: 5, length: 1053
JWE header: {"alg":"RSA-OAEP","enc":"A256CBC-HS512","kid":"bookstore-enc-2026-11","typ":"at+jwt","cty":"JWT"}
valid: True, email: ada@bookstore.test

Beş parça: header, şifrelenmiş içerik anahtarı, IV (Initialization Vector), şifreli metin ve authentication tag. alg içerik anahtarının nasıl şifrelendiğini (RSA-OAEP: Optimal Asymmetric Encryption Padding), enc içeriğin nasıl şifrelendiğini söylüyor. Bedeli boyut ve iş yükü: aynı claim'ler 458 karakterden 1053'e çıktı ve doğrulayan tarafın private key'e ihtiyacı var. JWS ile JWE'yi birbirinden ayırmak doğrulayıcının işi; güncellenmekte olan JWT BCP (Best Current Practice) taslağı draft-ietf-oauth-rfc8725bis (21 Ağustos 2026 tarihli -10 revizyonu, henüz RFC değil) bunu "encryption-signature confusion" başlığıyla ayrı bir tehdit olarak ele alıyor: nested JWT'de hem dış şifrelemenin hem de iç imzanın doğrulanması gerekir.

Algoritma Seçimi: HS256, RS256, ES256

HS256 (HMAC-SHA256: Hash-based Message Authentication Code + Secure Hash Algorithm 256 bit) simetriktir: imzalayan ve doğrulayan aynı secret'ı bilir. Tek bir uygulama hem token'ı üretip hem doğruluyorsa sorun değil. Ama Bookstore'da token'ı doğrulayan beş servis varsa beşi de secret'ı bilir ve herhangi biri ele geçirilirse saldırgan istediği token'ı üretebilir. Doğrulayıcının imzalayamaması gereken her dağıtık sistemde asimetrik algoritma kullan: RS256 (RSA imzası, Public-Key Cryptography Standards (PKCS) #1 v1.5 biçiminde, Secure Hash Algorithm 256 (SHA-256) ile) ya da ES256 (P-256 eğrisi üzerinde ECDSA (Elliptic Curve Digital Signature Algorithm)). ES256'nın imzası 64 byte, 2048 bit RSA'nın imzası 256 byte; key üretimi de çok daha hızlı. Bookstore ES256 kullanıyor.

Algoritmayı token değil sunucu seçer. Lab API'si yalnız ES256 kabul ediyor ve typ'i de kontrol ediyor:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = issuer;
        options.RequireHttpsMetadata = false; // yalnız lab: issuer http://localhost üzerinde
        options.Audience = "bookstore-api";
        options.MapInboundClaims = false;
        options.RefreshInterval = TimeSpan.FromSeconds(builder.Configuration.GetValue("Lab:RefreshSeconds", 300));
        options.TokenValidationParameters.ValidTypes = ["at+jwt"];
        options.TokenValidationParameters.ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256];
        options.TokenValidationParameters.ClockSkew = TimeSpan.FromSeconds(30);
    });

Kendi lab'ıma karşı üç klasik deneme yaptım: imzasız bir token (alg: none), public JWKS'i HMAC secret'ı gibi kullanarak HS256 ile imzalanmış bir token ve payload'ı sub: grace olarak değiştirilmiş bir token:

== alg none
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The signature is invalid"
IDX10504: Unable to validate signature, token does not have a signature: ...
== HS256 signed with the public JWK as secret
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The signature is invalid"
IDX10511: Signature validation failed. Keys tried: 'Microsoft.IdentityModel.Tokens.ECDsaSecurityKey, KeyId: 'bookstore-2026-11-a', ...
== payload edited (sub=grace)
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The signature is invalid"
IDX10511: Signature validation failed. Keys tried: 'Microsoft.IdentityModel.Tokens.ECDsaSecurityKey, KeyId: 'bookstore-2026-11-a', ...

Üçü de reddedildi. İkincisi, RS256 kullanan sistemlerde tarihsel olarak ciddi açıklara yol açan "algorithm confusion" saldırısı: doğrulayıcı token'daki alg'a güvenip public key'i HMAC secret'ı olarak kullanırsa, public key'i bilen herkes geçerli token üretir. Savunma iki katmanlı: ValidAlgorithms ile izin listesi ve her key'in tek bir algoritmayla kullanılması. 8725bis taslağı da algoritmalar için yasak listesi değil izin listesi istiyor.

typ ve Cross-JWT Confusion

Aynı issuer birden çok türde JWT üretir: access token, ID token, belki e-posta doğrulama token'ı. Hepsi aynı key'le imzalanmışsa, biri diğerinin yerine kullanılabilir mi? ValidTypes = ["at+jwt"] bunu engelliyor. Token servisine typ: JWT ile bir token ürettirip API'ye gönderdim:

== typ JWT
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token"
IDX10257: Token type validation failed. Type: 'JWT'. Did not match: validationParameters.TokenTypes: '[PII of type 'System.String' is hidden. ...]'.

8725bis taslağı burada net: yeni JWT kullanımları için açık bir typ değeri önerilir ve genel JWT değeri açık tipleme sayılmaz. Aynı issuer'ın farklı JWT türleri için doğrulama kuralları birbirini dışlamalı. Audience da aynı işi başka bir eksende yapıyor: Bookstore'un yönetim API'si için üretilmiş bir token sipariş API'sinde geçmemeli.

== wrong audience
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The audience '(null)' is invalid"
IDX10214: Audience validation failed. See https://aka.ms/identitymodel/app-context-switches

Cevaptaki '(null)' yanıltıcı: token'da audience var, ama library PII (Personally Identifiable Information) gizleme nedeniyle değeri mesaja koymuyor. error_description'a hata ayıklarken güvenme, log'a bak.

kid ve JWKS

JWKS, issuer'ın public key'lerini yayınladığı JSON (JavaScript Object Notation) belgesidir. Her key'in bir kid'i (key ID) vardır, token header'ındaki kid doğrulayıcıya hangi key'i kullanacağını söyler. Lab'ın JWKS'i:

{"keys":[{"kty":"EC","use":"sig","alg":"ES256","kid":"bookstore-2026-11-a","crv":"P-256","x":"xZJQNOor3tjv2aOM_h3NATbzJRiVLXZba2gY1b0W_tE","y":"zX2MZv-npSZz-yWXyu-0kPu0zu4IfhKb5IDsLIrj_HA"}]}
app.MapGet("/.well-known/jwks.json", (SigningKeyRing ring) => TypedResults.Ok(new
{
    keys = ring.Published.Select(key =>
    {
        JsonWebKey jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(key);
        return new { kty = jwk.Kty, use = "sig", alg = "ES256", kid = jwk.Kid, crv = jwk.Crv, x = jwk.X, y = jwk.Y };
    })
}));

API, Authority ayarından /.well-known/openid-configuration'ı, oradan da jwks_uri'yi bulur ve key'leri bir ConfigurationManager içinde cache'ler. Private key'ler (d parametresi) asla JWKS'e girmez; ConvertFromECDsaSecurityKey private key içeren bir key'den çağrılırsa d'yi de doldurur, bu yüzden yanıtı alan alan kendim kuruyorum. kid doğrulayıcı açısından güvenilmeyen bir girdidir: 8725bis, kid'in bir dosya yolu ya da SQL (Structured Query Language) sorgusunda kullanılmadan önce temizlenmesini, jku ve x5u gibi URL (Uniform Resource Locator) taşıyan header'lara da izin listesi olmadan güvenilmemesini istiyor.

Kesintisiz Key Rotation

Varsayılan cache süreleri, rotation planının temelidir:

DefaultAutomaticRefreshInterval=12:00:00
DefaultRefreshInterval=00:05:00
ClockSkew default=00:05:00

AutomaticRefreshInterval: API, metadata'yı ve JWKS'i en geç 12 saatte bir yeniler. RefreshInterval: bilinmeyen bir kid görünce zorunlu yenileme isteyebilir, ama iki yenileme arasında en az 5 dakika bekler. Baştaki deneyde bu süreyi 10 saniyeye indirmiştim. Yenileme isteği o anki request'i kurtarmıyor; bir sonraki request'te yeni JWKS kullanılıyor. O yüzden ilk B token'ı 401, ikincisi 200 aldı.

Sıra yanlış olursa, yani yeni key yayınlanmadan imzalamaya geçilirse durum çok daha kötü:

token A -> 200
switch-without-publish: {"active":"bookstore-2026-11-b","published":["bookstore-2026-11-a"]}
B #1 -> 401, B #2 -> 401
B after 11 s -> 401

Microsoft.IdentityModel.Tokens.SecurityTokenSignatureKeyNotFoundException: IDX10503: Signature validation failed. The token's kid is: 'bookstore-2026-11-b', but did not match any keys in TokenValidationParameters or Configuration. ...

API JWKS'i yeniliyor, ama orada B yok; yeni token'ların hepsi reddediliyor. Kesintisiz rotation üç adımdır:

// Üç adımda rotation: yeni key'i yayınla, onunla imzala, eskisini kaldır
public void Apply(string step)
{
    switch (step)
    {
        case "publish-next": Published = [_a, _b]; break;
        case "switch": Active = _b; break;
        case "retire-old": Published = [_b]; break;
        case "switch-without-publish": Active = _b; break;
        default: throw new ArgumentOutOfRangeException(nameof(step));
    }
}

Birinci adımla ikinci adım arasında en az bir AutomaticRefreshInterval (varsayılanla 12 saat) bekle; böylece her doğrulayıcı yeni key'i önceden cache'ine almış olur ve baştaki tek başarısız istek bile yaşanmaz. İkinci adımla üçüncü arasında en az access token ömrü (Bookstore'da 5 dakika) artı ClockSkew kadar bekle; eski key'le imzalanmış token'lar süresi dolana kadar doğrulanabilmeli. Lab'da eski key'i JWKS'ten kaldırdıktan hemen sonra A token'ı hâlâ 200 aldı, çünkü API'nin cache'inde A duruyordu: kaldırmak da yayınlamak gibi gecikmeli etki eder. Key ele geçirildiyse bu gecikme bir sorun; o durumda API'leri yeniden başlatmak ya da metadata'yı elle yenilemek gerekir.

Token Boyutu ve Claim Şişmesi

JWS with 0 permission claims: 350 chars
JWS with 40 permission claims: 2109 chars

Her yetkiyi bir claim olarak token'a koymak cazip, ama token her istekte Authorization header'ında gidiyor. 40 permission claim'i token'ı altı katına çıkardı; birkaç yüz yetkili bir yönetici hesabı proxy'lerin header sınırlarına takılır. Ayrıca token'daki yetki, token'ın ömrü boyunca donmuş durumda: yetkiyi geri alsan da token'ın süresi dolana kadar geçerli. Token'a rol ya da kaba bir scope koyup ayrıntılı yetkiyi API tarafında çözmek genelde daha iyi; bu tasarımın ayrıntısı 16. yazıda.

Üretimde Dikkat

Hata mesajlarını istemciye detaylı döndürme. JwtBearer'ın IncludeErrorDetails ayarı varsayılan olarak açık ve error_description'ı dolduruyor; production'da kapatmayı düşün, teşhisi log'dan yap. Log'larda ise PII varsayılan olarak gizli; IdentityModelEventSource.ShowPII'ı yalnız geliştirmede aç.

Ölç: Microsoft.AspNetCore.Authentication metriklerindeki authenticated request süresi, JWKS yenilemelerinden sonra sıçrama yapıyorsa metadata endpoint'i yavaş demektir. Token servisinin JWKS endpoint'ini cache'lenebilir yap ve yüksek erişilebilirlikle çalıştır; o endpoint düşerse yeni key'e geçiş anında API'ler hiçbir token'ı doğrulayamaz.

Private key'i kodda ya da repository'de tutma. Lab'daki PEM (Privacy-Enhanced Mail) dosyaları çalışma klasöründe openssl ile üretildi; production'da bir HSM (Hardware Security Module) ya da bulut key vault'u, imzalamayı key'i dışarı vermeden yapar. Secret yönetiminin ayrıntısı ilerideki Configuration ve Secret Management serisinde.

Sık Düşülen Tuzaklar

Birincisi, JWT'yi session yerine koyup iptal edememek. İmzalı bir token süresi dolana kadar geçerlidir; logout, parola değişikliği ya da yetki geri alma onu durdurmaz. Çözüm kısa ömürlü access token ve sunucuda tutulan, iptal edilebilir bir refresh token; bir sonraki yazının konusu.

İkincisi, yeni key'i yayınladığı anda onunla imzalamaya başlamak. Üçüncüsü, eski key'i JWKS'ten erken kaldırmak. Dördüncüsü, ValidTypes ve ValidAlgorithms'ı boş bırakmak; library'nin varsayılanları seni çoğu durumda korur ama 8725bis'in uyarıları tam olarak varsayılanların yetmediği köşelerle ilgili.

Beşincisi, JWE'yi gizlilik için değil "daha güvenli" olsun diye kullanmak. Şifreleme token'ı istemciden gizler ama iptal, ömür ve audience sorunlarının hiçbirini çözmez; üstüne boyut ve key yönetimi ekler.

Küçük Bir Deneme

★☆☆ Lab'ı --Lab:RefreshSeconds=300 ile çalıştır ve baştaki publish + switch deneyini tekrarla. Kaç istek başarısız oluyor ve neden? Ardından publish ile switch arasına API'yi yeniden başlatmayı ekle ve sonucu karşılaştır.

★★☆ Token servisine RS256 ile imzalayan üçüncü bir key ekle ve JWKS'te yayınla. API'nin ValidAlgorithms'ını değiştirmeden bu key'le imzalanmış bir token'ın reddedildiğini göster; sonra iki algoritmayı da kabul edecek şekilde güncelle ve her key'in yalnız bir algoritmayla eşleştiğini doğrula.

★★★ SigningKeyRing'i zaman tabanlı yap: her key'in publishAt, activateAt ve retireAt değerleri olsun ve adımlar elle değil zamana göre uygulansın. TimeProvider ile sahte saati ilerleterek üç adımın doğru sırayla gerçekleştiğini bir test ile göster.

JWT'nin en büyük zayıflığı iptal edilememesi. Bir sonraki yazıda bunu dengeleyen mekanizmayı kuruyoruz: refresh token'lar, rotation, çalınan bir token'ın tekrar kullanımını yakalayan reuse detection ve sunucu tarafında güvenli saklama.