Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Microservices — #10

Microservice'lerde Güvenlik: Edge Authentication, Token Propagation ve Service-to-Service Trust

Mert Özen 8 Kas 2026 10 dk 3 görüntülenme
Microservice'lerde Güvenlik: Edge Authentication, Token Propagation ve Service-to-Service Trust

Microservice'lerde authentication edge'de, ince taneli authorization her service'in içinde yapılır; kullanıcının token'ı zincir boyunca olduğu gibi taşınmaz, her sınırda token exchange ile dar audience'lı ve actor'ı belli bir token alınır, kullanıcısız çağrılar client credentials ya da mTLS ile kimlik kanıtlar. Audience doğrulamasının gerçek 401 çıktısı, confused deputy örneği ve düzeltmesi, zero trust ve secret yönetimi.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir; token doğrulama Microsoft.AspNetCore.Authentication.JwtBearer 10.0.14 ile yapıldı.

Önce Tahmin Et

Ada, Bookstore'un web sitesinde "siparişimi iade et" butonuna basıyor. İstek Web BFF'e (Backend for Frontend) gidiyor, BFF Ordering'i çağırıyor, Ordering de parayı geri ödemesi için Payments'ı. Ekipte iki öneri var. Birincisi: "Kullanıcının access token'ını zincir boyunca olduğu gibi taşıyalım, her service kimin istediğini görsün." İkincisi: "Ordering, Payments'a kendi service kimliğiyle gitsin; Payments, Ordering'e güveniyor nasılsa. Kullanıcıyı Ordering zaten doğruladı."

Payments'ın iade endpoint'i, ikinci öneriyle yazılmış hâliyle yalnız çağıranın payments.refund yetkisi olup olmadığına bakıyor. Ada, isteğinde kendi sipariş numarası yerine Grace'in sipariş numarasını gönderiyor ve Ordering'deki bir bug bu numarayı Ada'ya ait mi diye kontrol etmeden Payments'a iletiyor. Payments ne yapar? Birinci öneri bu sorunu çözer mi, yoksa başka bir sorun mu yaratır?

Edge'de Authentication, İçeride Authorization

Monolith'te güvenlik sınırı tekti: istek uygulamaya girerken authentication (kimlik doğrulama) yapılır, sonra her şey aynı process'in içinde, aynı ClaimsPrincipal ile olurdu. Microservice'lerde her service ayrı bir network endpoint'i ve her çağrı bir sınır geçişi. Sam Newman'ın Building Microservices (2. baskı, 2021) kitabının "Security" bölümündeki ayrım bugün yerleşik kabul görüyor: edge'de (gateway ya da BFF) kaba taneli authentication, service'lerin içinde ince taneli authorization (yetkilendirme). OWASP'ın (Open Worldwide Application Security Project) Microservices Security Cheat Sheet'i de aynı şeyi söylüyor: gateway'deki kontrol, yetkisiz istekleri kapıda çevirir ama downstream'deki bir işlemin yetkili olduğunu kanıtlamaz; her service kendi işlemlerine, iç çağrılar dahil, erişimi kendisi denetler.

Sebebi basit. "Ada bu siparişi iade ettirebilir mi?" sorusunun cevabı siparişin kime ait olduğuna, durumuna, iade süresine bağlı ve bu bilgilerin sahibi Ordering ile Payments. Gateway'in bunu bilmesi, bir önceki yazılardaki god gateway tuzağına düşmek demek. Edge'in işi kimliği doğrulamak ve bu kimliği içeriye güvenilir biçimde taşımak; içerideki her service'in işi de o kimlikle "bu işlem bu kişiye açık mı?" sorusunu sormak. IDOR (Insecure Direct Object Reference) açığı, OWASP serisinin "OWASP A01: Broken Access Control (1) — IDOR, Admin Endpoint'leri ve ASP.NET Core'da Yetkilendirme" yazısında işlediğimiz hâliyle, microservice'lerde tam bu sorunun service sınırında sorulmamasıyla ortaya çıkıyor.

Geriye iki soru kalıyor: kullanıcının kimliği içeriye hangi biçimde taşınır, ve bir service'e gelen isteğin gerçekten bir Bookstore service'inden geldiği nereden bilinir?

Kullanıcının Token'ını Olduğu Gibi Taşımak

İlk öneri, BFF'in elindeki access token'ı zincirdeki her çağrıya eklemek. Uygulaması kolay ama bir sorunu var: bu token BFF için kesilmiş. Token'daki aud (audience) claim'i, token'ın hangi alıcı için üretildiğini söyler; BFF'in token'ı Payments'ta da geçerli sayılırsa, o token'ı ele geçiren biri (bir log satırından, sızdırılmış bir service'ten) onunla doğrudan Payments'a gidebilir. RFC (Request for Comments) 9068'in JWT (JSON Web Token) access token profili de resource server'ın audience'ı doğrulamasını şart koşuyor. Payments'ın JWT bearer ayarı bu yüzden yalnız kendi adını kabul ediyor:

// Yalnız demo: gerçekte token'ları STS asimetrik anahtarla imzalar, service yalnız public key'i bilir
SymmetricSecurityKey signingKey = new(Convert.FromHexString("8f3c1a9e5b7d2f4061c8e3a7b9d5f1e2a4c6b8d0e2f4a6c8b0d2e4f6a8c0b2d4"));
const string Issuer = "https://login.bookstore.example";

WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.Services.AddAuthentication().AddJwtBearer(options =>
{
    options.MapInboundClaims = false;
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidIssuer = Issuer,
        ValidAudience = "payments", // Payments yalnız kendisi için kesilmiş token'ı kabul eder
        IssuerSigningKey = signingKey,
    };
    options.Events = new() { OnAuthenticationFailed = ctx => { Console.WriteLine($"  payments log: {ctx.Exception.GetType().Name}"); return Task.CompletedTask; } };
});

Ada'nın BFF için kesilmiş token'ı (aud: bookstore-web-bff) olduğu gibi Payments'a gelince:

  payments log: SecurityTokenInvalidAudienceException
forwarded user token: 401  | Bearer error="invalid_token", error_description="The audience '(null)' is invalid"

Payments token'ı reddetti. WWW-Authenticate header'ındaki açıklamada audience değeri '(null)' görünüyor, çünkü bu sürümde exception yanlış audience'ı taşımıyor; asıl sebep service'in kendi log'unda. Buradan iki sonuç çıkıyor. Kullanıcının token'ını taşımak istiyorsan ya token'ın audience'ını bütün service'leri kapsayacak kadar geniş tutacaksın (her service'in, diğer her service'e gidebilen bir token'ı görmesi demek; bir service'in sızması hepsinin sızması) ya da her sınırda yeni, dar bir token alacaksın. İkincisinin standart adı token exchange. Kullanıcının token'ını taşımanın bir sorunu daha var: zincirin ortasındaki service'in kim olduğu token'da yazmıyor; Payments, isteği Ordering'in mi yoksa token'ı bir şekilde ele geçirmiş başka bir şeyin mi gönderdiğini bilemiyor.

Token Exchange: Kullanıcı Adına, Kendi Kimliğiyle

RFC 8693 (OAuth 2.0 Token Exchange, Ocak 2020), bir service'in elindeki token'ı authorization server'a, yani bir STS'e (Security Token Service) götürüp başka bir alıcı için yeni bir token almasını standartlaştırıyor. Ordering, Ada'nın token'ını subject_token olarak, kendi kimliğini de client authentication olarak gönderir ve audience=payments ister. RFC iki semantiği ayırıyor. Impersonation'da yeni token'ı taşıyan, kullanıcıdan ayırt edilemez. Delegation'da ise token iki kimliği birden taşır: sub hâlâ Ada'dır, act (actor) claim'i de onun adına hareket edenin Ordering olduğunu söyler. Service'ler arası çağrılarda delegation'ı tercih etmek gerekiyor, çünkü Payments hem "kimin adına" hem "kim tarafından" sorusunu cevaplayabiliyor ve log'a ikisini birden yazabiliyor.

Örnekte exchange sonrası token'ı şöyle temsil ettim: sub = ada, aud = payments, scope = payments.refund, act = { "sub": "ordering" }. Token'ın ömrü kısa (birkaç dakika) ve yalnız Payments'ta geçerli. Bir sızıntıda zararı tek bir service'le ve birkaç dakikayla sınırlı. Bedeli, her sınır geçişinde STS'e giden bir çağrı ve STS'in artık kritik bir component olması; pratikte exchange edilen token'lar süreleri dolana kadar cache'lenir.

Service-to-Service Trust: Client Credentials ve mTLS

Kullanıcının olmadığı çağrılar da var: gece çalışan bir job'ın Inventory'den stok raporu çekmesi, Shipping'in OrderPlaced event'ini işlerken Ordering'den adres sorması. Burada service kendi adına konuşur ve OAuth 2.0'ın (RFC 6749) client credentials grant'i bunun için var: service, STS'e kendi client kimliği ve sırrıyla (ya da bir sertifikayla) gider, kendi adına, belirli bir audience ve scope için token alır.

İkinci yol mTLS. Sıradan bir TLS (Transport Layer Security) bağlantısında yalnız sunucu sertifika gösterir, mTLS'te (mutual TLS) istemci de gösterir ve iki taraf birbirini kriptografik olarak tanır. RFC 8705, OAuth client'larının mTLS ile kimlik doğrulamasını ve token'ın istemci sertifikasına bağlanmasını (certificate-bound token) tanımlıyor; böylece çalınan token, sertifikanın private key'i olmadan kullanılamıyor. mTLS'in zor tarafı sertifikaların kendisi: üretmek, dağıtmak, kısa ömürlü tutup döndürmek, iptal etmek. Bu yüzden mTLS'i elle değil bir platformla yapmak gerekiyor; bir önceki yazıda andığımız service mesh'ler bunu otomatik yapıyor, SPIFFE (Secure Production Identity Framework for Everyone) de workload'lara platformdan bağımsız, kısa ömürlü kimlik vermenin açık standardı.

Bookstore için gerçekçi başlangıç: service'ler arası her çağrıda kısa ömürlü bir token (kullanıcı varsa exchange ile delegation, yoksa client credentials) ve platformun sağladığı TLS. mTLS, compliance gerektirdiğinde ya da mesh zaten kurulu olduğunda gelir.

Confused Deputy: Yetkiyi Ödünç Vermek

Önce Tahmin Et'teki ikinci öneriye dönelim. Norm Hardy 1988'de "The Confused Deputy" adıyla bir problemi tarif etti: kendi yetkisi geniş olan bir program (deputy), daha az yetkili birinin isteğini yerine getirirken kendi yetkisini kullanır ve böylece isteyenin aslında yapamayacağı bir işi yapmış olur. Microservice'lerde bunun en yaygın biçimi, güçlü bir service kimliğiyle kullanıcı adına iş yapmak. Payments'ın iki sürümü:

// Naif sürüm: yalnız scope'a bakar, kimin adına çağrıldığını sormaz
payments.MapPost("/naive/refunds/{orderId}", (string orderId, ClaimsPrincipal caller) =>
    caller.HasScope("payments.refund")
        ? Results.Ok($"refunded {orderId} (caller: {caller.FindFirstValue("sub")})")
        : Results.Forbid())
    .RequireAuthorization();

// Düzeltilmiş sürüm: kullanıcı bağlamı zorunlu, sipariş o kullanıcıya ait olmalı
payments.MapPost("/refunds/{orderId}", (string orderId, ClaimsPrincipal caller) =>
{
    if (!caller.HasScope("payments.refund"))
        return Results.Forbid();
    string? user = caller.FindFirstValue("sub");
    string? actor = caller.FindFirstValue("act") is { } act
        ? JsonDocument.Parse(act).RootElement.GetProperty("sub").GetString()
        : null;
    if (actor is null || orderOwners.GetValueOrDefault(orderId) != user)
        return Results.Forbid();
    return Results.Ok($"refunded {orderId} for {user} (actor: {actor})");
}).RequireAuthorization();

Ada, Grace'in siparişi G-2002 için iade istiyor; Ordering önce kendi client credentials token'ıyla, sonra token exchange'den gelen delegation token'ıyla Payments'a gidiyor:

// 2) Ordering kendi client credentials token'ıyla, Ada'nın isteği üzerine Grace'in siparişini iade ettiriyor
string orderingOwnToken = Issue("ordering", "payments", "payments.refund");
await Call("naive, service token, G-2002", "/naive/refunds/G-2002", orderingOwnToken);
await Call("fixed, service token, G-2002", "/refunds/G-2002", orderingOwnToken);
// 3) Token exchange sonrası: sub = ada, act.sub = ordering, aud = payments
string exchanged = Issue("ada", "payments", "payments.refund", actor: "ordering");
await Call("fixed, exchanged token, G-2002", "/refunds/G-2002", exchanged);
await Call("fixed, exchanged token, A-1001", "/refunds/A-1001", exchanged);
naive, service token, G-2002: 200 "refunded G-2002 (caller: ordering)"
fixed, service token, G-2002: 403
fixed, exchanged token, G-2002: 403
fixed, exchanged token, A-1001: 200 "refunded A-1001 for ada (actor: ordering)"

Naif sürümde Grace'in parası Ada'nın isteğiyle iade edildi ve Payments'ın log'unda yalnız "caller: ordering" yazıyor; kimin istediği kayıtlı bile değil. Ordering'deki tek bir eksik kontrol, Payments'ın bütün yetkisini Ada'ya ödünç verdi. Düzeltilmiş sürüm iki şey istiyor: işlem bir kullanıcı adına yapılıyorsa token'da o kullanıcı (sub) ve adına hareket eden service (act) olmalı, sipariş de o kullanıcıya ait olmalı. Aynı Ordering, aynı bug, ama bu kez Payments kendi verisiyle kontrol yapıyor ve 403 dönüyor. Bu, savunmanın derinlemesine (defense in depth) olmasının somut hâli: Ordering'deki kontrol birinci hat, Payments'taki kontrol ikinci hat ve ikincisi birincinin doğru çalıştığını varsaymıyor.

Örnekte sipariş sahipliğini Payments'ın içindeki küçük bir sözlükte tuttum; gerçekte Payments, ödeme kaydını oluştururken müşteri kimliğini kendi veritabanına yazar ve iade anında onunla karşılaştırır. Serinin "Data Ownership ve Database per Service" yazısındaki ilke burada güvenlik kararına dönüşüyor: Payments yetki kararını başka bir service'in "bana güven" demesine değil, kendi verisine dayandırıyor.

Zero Trust: Ağın İçi de Güvenilmez

Klasik ağ güvenliği bir kale gibi düşünür: dışarısı tehlikeli, firewall'ın içi güvenli. NIST'in (National Institute of Standards and Technology) 2020'de yayımladığı Special Publication 800-207, bu varsayımı bırakan zero trust mimarisini tanımlıyor: bir kaynağa erişim, isteğin ağın neresinden geldiğine değil, kimliğe ve o anki bağlama göre, her istek için ayrı ayrı verilir. Microservice'ler için pratik karşılığı şu: Inventory'ye gelen bir isteğin cluster'ın içinden gelmesi, ona güvenmek için yeterli bir sebep değil. Bir service ele geçirildiğinde saldırgan da cluster'ın içindedir.

Bookstore'da bunun anlamı, her service'in her isteği (iç çağrılar dahil) doğrulaması, network policy'lerle hangi service'in hangisine konuşabileceğinin sınırlanması (Notification'ın Payments'a erişmesine gerek yok) ve token'ların kısa ömürlü, dar audience'lı olması. Zero trust bir ürün değil; yukarıdaki kararların toplamı.

Secret Yönetimi

Service sayısı arttıkça secret sayısı da artıyor: her service'in client secret'ı, veritabanı bağlantısı, broker parolası, ödeme sağlayıcısının API (Application Programming Interface) anahtarı. Monolith'teki "hepsi tek bir appsettings.Production.json'da" alışkanlığı burada hem güvenlik hem işletme sorunu olur. OWASP serisinin "OWASP A04: Cryptographic Failures (2) — Parola Saklama, Data Protection ve Secret Yönetimi" yazısında temel kuralları işledik; microservice'lere özgü olanlar şunlar. Her service yalnız kendi secret'larını görür; Notification, Payments'ın sağlayıcı anahtarını okuyamaz. Secret'lar merkezi bir secret store'da (Azure Key Vault, HashiCorp Vault ya da bulutun karşılığı) durur ve service'ler oraya kendi workload kimlikleriyle erişir; böylece "secret'a erişmek için gereken secret" sorunu da ortadan kalkar. Mümkün olan yerde secret yerine kısa ömürlü kimlik kullanılır: client secret yerine workload identity ya da sertifika. Kubernetes Secret'larının varsayılan olarak yalnız base64 ile kodlandığını, şifrelenmediğini de unutma; etcd'de encryption at rest ayrıca açılır.

Sık Düşülen Tuzaklar

İç ağa güvenmek

"Bu endpoint dışarıya açık değil, authentication'a gerek yok" demek, ele geçirilen ilk service'e bütün iç endpoint'leri açmak demek. İç endpoint'ler de token ister.

Tek bir geniş audience'lı token

Bütün service'lerde geçerli bir token pratik görünür, ama bir service'in log'una düşen token bütün sistemin anahtarı olur. Audience'ı dar tut, exchange'in bedelini öde.

Güçlü service kimliğiyle kullanıcı işi yapmak

Yukarıdaki confused deputy örneği. Bir işlem bir kullanıcı adına yapılıyorsa, kullanıcı bağlamı son service'e kadar taşınmalı ve son service yetkiyi kendi verisiyle kontrol etmeli.

Authorization'ı yalnız gateway'de yapmak

Gateway "Ada giriş yapmış" der; "Ada bu siparişi iade ettirebilir" demez. İnce taneli yetki service'te kalır.

Uzun ömürlü secret'ları paylaşmak

Birden fazla service'in aynı client secret'ı kullanması, hangi service'in ne yaptığını log'larda ayırt edilemez kılar ve bir sızıntıda hepsini birden döndürmeyi gerektirir.

Küçük Bir Deneme

★☆☆ Örnekteki BFF token'ını aud olarak hem bookstore-web-bff hem payments içerecek şekilde üret (birden fazla audience) ve Payments'a tekrar gönder. Kabul ediliyor mu? Bu tasarımın riskini bir cümleyle yaz.

★★☆ Düzeltilmiş endpoint'e, act claim'indeki actor'ın yalnız ordering olabileceği kuralını ekle. Notification, bir kullanıcının token'ını exchange edip iade istemeye kalkarsa ne olmalı? Çıktıyla doğrula.

★★★ Bookstore için bir güven haritası çiz: hangi service hangisini çağırabilir, hangi çağrı kullanıcı adına (delegation), hangisi kendi adına (client credentials), hangi token hangi audience ve scope'u taşır? Haritada Notification'ın Payments'a giden bir oku var mı, olmalı mı?

Service'ler artık birbirini buluyor ve birbirine güvenli konuşuyor. Ama production'da bir sipariş beş service'ten geçerken yavaşladığında, hangi service'te takıldığını nasıl göreceğiz, ve bir service düştüğünde diğerleri onunla birlikte düşmesin diye ne yapacağız? Bir sonraki yazıda production'da microservice işletmeye bakacağız.