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

API2 Machine-to-Machine Authentication: API Key Tasarımı, HMAC İmzalı İstekler ve Token Endpoint'lerini Korumak

Mert Özen 8 Kas 2026 15 dk 2 görüntülenme
API2 Machine-to-Machine Authentication: API Key Tasarımı, HMAC İmzalı İstekler ve Token Endpoint'lerini Korumak

Makineler arası bir API key; tanınabilir prefix'li, yalnız hash'i saklanan, tenant ve scope'a bağlı, iki aktif key ile rotation'a ve revocation'a izin veren bir formatla tasarlanır ve asla query string'de taşınmaz. Custom authentication handler, timestamp ve body hash'iyle HMAC imzalı istekler, replay koruması ve parola sıfırlama gibi endpoint'lerde IP ve hesap başına limitleri testleriyle kuruyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ve EF (Entity Framework) Core 10.0.14 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un North mağazası siparişlerini bir kargo firmasıyla paylaşmak istiyor. Kargo firması her saat başı yeni siparişleri çekecek, paketi yola çıkardığında da takip numarasını geri gönderecek. Ekipten biri şöyle diyor: "Bir API (Application Programming Interface) key üretelim, veritabanına yazalım, firma da ?api_key=... ile çağırsın." Bu cümlede kaç ayrı sorun var?

Ben dört sayıyorum. Key'in düz metin saklanması, query string'de taşınması, hangi tenant'a ve hangi işlemlere bağlı olduğunun söylenmemesi ve değiştirilmesi gerektiğinde ne olacağının düşünülmemesi. Beşincisi de üstüne gelebilir: takip numarasını gönderen isteğin yolda değiştirilmediğini ve tekrar oynatılmadığını nereden bileceğiz?

Bu yazı API2'nin (OWASP API Security Top 10 2023'teki adıyla Broken Authentication) makineler arası tarafı. Kullanıcı login'i, brute force ve lockout'un temeli Web Güvenliği (OWASP Top 10) serisinin A07 Authentication Failures yazısında; OAuth ve token doğrulama ise ilerideki Authentication ve Authorization serisinin konusu. Burada B2B (Business to Business) entegrasyonlar için API key tasarımına, HMAC (Hash-based Message Authentication Code) ile imzalı isteklere ve kimlik doğrulamayla ilgili endpoint'leri sertleştirmeye odaklanıyoruz.

API Key Ne Zaman Doğru Araç?

OWASP (Open Worldwide Application Security Project) API2 sayfası açık bir çizgi çiziyor: API key'ler kullanıcıları değil, yalnız API istemcilerini doğrulamak için kullanılmalı. Bir API key "bu istek ParcelCo entegrasyonundan geliyor" der; "bu istek Alan'dan geliyor" demez. Arkasında bir insan, bir oturum ya da bir MFA (Multi-Factor Authentication) adımı yoktur.

İkinci soru, API key mi yoksa OAuth 2.0 client credentials mı. Elinde bir authorization server varsa ve karşı taraf token almayı biliyorsa client credentials daha iyi bir seçim: kısa ömürlü access token, standart doğrulama, merkezi revocation. Ama birçok B2B entegrasyonda karşı taraf küçük bir firma, elinde bir cron job ve bir curl komutu var. O durumda iyi tasarlanmış bir API key, kötü uygulanmış bir OAuth akışından daha güvenlidir. Bu yazıda o "iyi tasarlanmış" kısmını kuruyoruz; client credentials ve DPoP (Demonstrating Proof of Possession) Authentication ve Authorization serisinin API'yi korumak üzerine yazısında gelecek.

Tanınabilir Bir Key Formatı

İlk karar formatın kendisi. Key'imiz şöyle görünecek: bks_dev_6lu2rqhriac2_D9Vs9VK6.... Üç parçası var ve her birinin bir görevi var:

// bks_dev_<keyId>_<secret>: prefix tanınabilir (secret scanning), keyId arama için, secret yalnız hash olarak saklanır
public static class ApiKeyFormat
{
    public const string Prefix = "bks_dev_";
    private const string KeyIdAlphabet = "abcdefghijklmnopqrstuvwxyz234567";

    public static (string Key, string KeyId, byte[] SecretHash) Generate()
    {
        string keyId = RandomNumberGenerator.GetString(KeyIdAlphabet, 12);
        string secret = Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
        return ($"{Prefix}{keyId}_{secret}", keyId, Hash(secret));
    }

    public static bool TryParse(string key, out string keyId, out string secret)
    {
        keyId = secret = "";
        if (!key.StartsWith(Prefix, StringComparison.Ordinal) || key.Length != Prefix.Length + 12 + 1 + 43
            || key[Prefix.Length + 12] != '_')
        {
            return false;
        }
        keyId = key.Substring(Prefix.Length, 12);
        secret = key[(Prefix.Length + 13)..];
        return true;
    }

    // Secret 256 bit rastgele: yavaş parola hash'ine gerek yok, SHA-256 yeterli
    public static byte[] Hash(string secret) => SHA256.HashData(System.Text.Encoding.ASCII.GetBytes(secret));

    private static string Base64UrlEncode(byte[] bytes) =>
        Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}

Prefix, key'in bir log dosyasında, bir commit'te ya da bir Slack mesajında görüldüğünde tanınmasını sağlıyor. GitHub'ın token'larındaki ghp_ ön eki aynı amaca hizmet ediyor: secret scanning araçları bu ön eki arayıp sızan key'i yakalayabiliyor. Ortamı da prefix'e koymak (dev, production'da başka bir değer) yanlış ortama yapıştırılan key'i ilk bakışta belli ediyor.

keyId gizli değil; veritabanında key'i bulmak için kullandığımız index'li bir kolon. Secret ise 32 rastgele bayt, yani 256 bit. Hash için SHA (Secure Hash Algorithm) ailesinden SHA-256 kullanmamın sebebi bu entropi: parolalar için PBKDF2 (Password-Based Key Derivation Function 2) ya da Argon2 gibi yavaş hash'ler gerekir, çünkü insanların seçtiği parolalar tahmin edilebilir. 256 bitlik rastgele bir değeri deneme yanılmayla bulmak ise hash ne kadar hızlı olursa olsun mümkün değil. Parola saklama tarafını Web Güvenliği serisinin A04 Cryptographic Failures yazısında konuşmuştuk.

Yalnız Hash'i Saklamak ve Authentication Handler

Veritabanında key'in kendisi yok; yalnız KeyId, SecretHash, scope'lar, tenant ve zaman damgaları var. Doğrulama, ASP.NET Core'un authentication handler modeline oturan küçük bir class:

public sealed class ApiKeyAuthenticationHandler(
    IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder,
    ApiKeyStore store, TimeProvider time)
    : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
    public const string SchemeName = "ApiKey";

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        // Query string'deki key log'lara, proxy'lere ve tarayıcı geçmişine düşer: geçerli olsa bile reddet
        if (Request.Query.Keys.Any(k => k.Equals("api_key", StringComparison.OrdinalIgnoreCase)
                || k.Equals("apikey", StringComparison.OrdinalIgnoreCase)))
        {
            return AuthenticateResult.Fail("API keys are not accepted in the query string.");
        }

        string? header = Request.Headers.Authorization;
        if (header is null || !header.StartsWith("ApiKey ", StringComparison.Ordinal))
        {
            return AuthenticateResult.NoResult();
        }

        if (!ApiKeyFormat.TryParse(header["ApiKey ".Length..].Trim(), out string keyId, out string secret))
        {
            return AuthenticateResult.Fail("Malformed API key.");
        }

        ApiKey? key = await store.FindAsync(keyId, Context.RequestAborted);
        DateTime now = time.GetUtcNow().UtcDateTime;
        if (key is null || !CryptographicOperations.FixedTimeEquals(key.SecretHash, ApiKeyFormat.Hash(secret))
            || key.RevokedAt is not null || key.ExpiresAt <= now)
        {
            // Log'a key'in kendisi değil, yalnız keyId yazılır
            Logger.LogWarning("API key authentication failed for key id {KeyId}", keyId);
            return AuthenticateResult.Fail("Invalid API key.");
        }

        await store.TouchAsync(key.Id, now, Context.RequestAborted);

        List<Claim> claims =
        [
            new(LabClaims.Subject, $"client:{key.Id}"),
            new(LabClaims.Tenant, key.TenantId),
            new(LabClaims.Role, "integration"),
            new("key_id", key.KeyId)
        ];
        claims.AddRange(key.Scopes.Split(' ').Select(scope => new Claim("scope", scope)));
        ClaimsIdentity identity = new(claims, SchemeName, LabClaims.Subject, LabClaims.Role);
        return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName));
    }
}

Birkaç kararın üzerinden geçelim. Karşılaştırma CryptographicOperations.FixedTimeEquals ile yapılıyor; iki hash'in kaçıncı baytta ayrıldığı yanıt süresinden okunamıyor. Hash karşılaştırmasında bu risk zaten küçük, ama ucuz bir alışkanlık. Key bulunamadı, hash tutmadı, revoke edilmiş ya da süresi dolmuş: dört durumda da aynı genel hata dönüyor. İstemciye "bu key revoke edildi" demek, elinde eski bir key olan birine o key'in bir zamanlar geçerli olduğunu doğrular. Log'a ise yalnız keyId yazılıyor; key'in kendisi hiçbir log satırına düşmüyor.

ApiKeyStore bilinçli olarak kapsamsız çalışıyor: key doğrulanırken henüz tenant bilinmiyor, tenant'ı key'in kendisinden öğreniyoruz. Bir önceki yazıda IgnoreQueryFilters() çağrısını tek dosyaya hapseden testi yazmıştık; bu dosya o testin allowlist'ine ikinci ve son giriş olarak eklendi, gerekçesiyle birlikte.

Scope, Tenant ve Policy'ler

Handler'ın ürettiği principal'da tenant_id claim'i var ve rolü integration. Bu, bir önceki yazıda kurduğumuz DataScope'un key'le gelen istekte de aynen çalışması demek: kargo firmasının key'i North'a bağlıysa, sorgular North'un verisiyle sınırlı. Policy'ler hem scheme'i hem scope'u şart koşuyor:

// Integration endpoint'leri yalnız API key kabul eder; kullanıcı JWT'si burada geçmez
.AddPolicy(IntegrationPolicies.OrdersRead, policy => policy
    .AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName)
    .RequireRole("integration")
    .RequireClaim("scope", "orders.read"))
.AddPolicy(IntegrationPolicies.ShipmentsWrite, policy => policy
    .AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName)
    .RequireRole("integration")
    .RequireClaim("scope", "shipments.write"));

AddAuthenticationSchemes kritik. Onsuz default scheme olan JwtBearer çalışırdı ve bir müşterinin JWT'si (JSON Web Token), rolü tutmasa bile, integration endpoint'lerinin kapısına kadar gelirdi. Şimdi integration endpoint'leri yalnız API key'i tanıyor. Ada'nın JWT'siyle bir key üretip deneyelim (key'i ve imza secret'ını yazıda kısalttım):

$ curl -s -X POST http://localhost:19513/admin/api-keys -H "Authorization: Bearer $ADA" -H "Content-Type: application/json" -d '{"clientName":"parcelco","scopes":["orders.read","shipments.write"]}'
{"id":"01a1b9ad-398f-7c70-9869-48fcd1f748c2","apiKey":"bks_dev_6lu2rqhriac2_D9Vs9VK6...","signingSecret":"ivWaadvx...","expiresAt":"2027-02-06T05:41:39.829771Z"}

$ curl -s http://localhost:19513/integrations/orders -H "Authorization: ApiKey $KEY"
[{"id":"0197f001-0000-7000-8000-000000000001","status":"Paid","total":54.9,"createdAt":"2026-11-02T10:00:00"},{"id":"0197f001-0000-7000-8000-000000000002","status":"Pending","total":54.9,"createdAt":"2026-11-02T10:00:00"}]

$ curl -i http://localhost:19513/integrations/orders -H "Authorization: Bearer $ALAN"
HTTP/1.1 401 Unauthorized
Content-Length: 0
Date: Sun, 08 Nov 2026 05:41:39 GMT
Server: Kestrel

$ curl -i "http://localhost:19513/integrations/orders?api_key=$KEY"
HTTP/1.1 401 Unauthorized
Content-Length: 0
Date: Sun, 08 Nov 2026 05:41:39 GMT
Server: Kestrel

Key yalnız North'un iki siparişini gördü; Linus'un siparişi listede yok ve bunun için integration endpoint'ine tek satır tenant kodu yazmadık. Alan'ın JWT'si 401 aldı. Son istekte key query string'de: doğru bir key olmasına rağmen reddedildi. OWASP API2'nin "zafiyet var mı?" listesinde token ya da parolanın URL'de (Uniform Resource Locator) gönderilmesi ayrı bir madde. URL'ler web sunucusu log'larına, reverse proxy'lere, APM (Application Performance Monitoring) araçlarına ve Referer header'ına düşer; header ise çoğu yerde varsayılan olarak loglanmaz. Sessizce kabul etmek yerine reddetmek, entegrasyonu yazan kişiyi daha ilk denemede doğru yola sokuyor.

Rotation, Revocation ve Son Kullanım Zamanı

Bir key'in hayatı üç olayla biter: süresi dolar, revoke edilir ya da yenisiyle değiştirilir. Üçüncüsü en çok ihmal edileni. Tek aktif key'e izin veren bir sistemde key değiştirmek kesinti demektir: yeni key üretildiği anda eskisi ölür, karşı taraf yeni key'i deploy edene kadar istekler 401 alır. Bu yüzden bir client için iki aktif key'e izin veriyoruz:

DateTime now = time.GetUtcNow().UtcDateTime;
// Rotation için aynı client'a en fazla iki aktif key: yenisi dağıtılırken eskisi çalışmaya devam eder
int active = await db.ApiKeys.CountAsync(k => k.ClientName == request.ClientName
    && k.RevokedAt == null && k.ExpiresAt > now, ct);
if (active >= IntegrationPolicies.MaxActiveKeysPerClient)
{
    return Results.Problem(statusCode: StatusCodes.Status409Conflict,
        title: "Revoke an existing key before creating a new one.");
}

Akış şu: ikinci key üretilir, karşı tarafa iletilir, karşı taraf geçer, LastUsedAt eski key'in artık kullanılmadığını gösterince eski key revoke edilir. Üçüncü bir key istemek 409 alır; bu, "eski key'leri temizlemeden yenisini üretme" disiplinini sisteme gömüyor. Her key 90 gün sonra kendiliğinden ölüyor. Revocation ve sonrası:

$ curl -s -X DELETE http://localhost:19513/admin/api-keys/01a1b9ad-398f-7c70-9869-48fcd1f748c2 -H "Authorization: Bearer $ADA" -o /dev/null -w "%{http_code}\n"
204
$ curl -s http://localhost:19513/integrations/orders -H "Authorization: ApiKey $KEY" -o /dev/null -w "%{http_code}\n"
401
$ curl -s http://localhost:19513/admin/api-keys -H "Authorization: Bearer $ADA"
[{"id":"01a1b9ad-398f-7c70-9869-48fcd1f748c2","clientName":"parcelco","keyId":"6lu2rqhriac2","scopes":"orders.read shipments.write","expiresAt":"2027-02-06T05:41:39.829771","revokedAt":"2026-11-08T05:41:40.043018","lastUsedAt":"2026-11-08T05:41:40.033007"}]

Listeleme endpoint'i ne key'i ne hash'i döndürüyor. Key yalnız üretildiği yanıtta görünüyor; kaybedilirse kurtarılmaz, yenisi üretilir. LastUsedAt revocation kararlarının veriye dayanması için var: "bu key'i kim kullanıyor, kapatırsam ne kırılır?" sorusunun cevabı burada. Doğrulama her istekte bu kolonu güncelliyor; çok yüksek trafikte bunu dakikada bir ile sınırlamak mantıklı olur.

HMAC ile İmzalı İstekler

API key "kim olduğunu" kanıtlıyor ama isteğin içeriği hakkında bir şey söylemiyor. TLS (Transport Layer Security) yolu koruyor, fakat TLS'in bittiği yerden sonrası, örneğin bir kurumsal proxy, bir log toplayıcı ya da karşı tarafın kendi altyapısı, isteği görebilir ve kaydedebilir. Kaydedilmiş bir isteği daha sonra tekrar göndermek (replay) ya da içindeki takip numarasını değiştirmek için key'i bilmek yetiyor. Gönderinin durumunu değiştiren endpoint'te bunu istemiyoruz.

İmzalanan metin şu dört parçadan oluşuyor:

// İmzalanan metin: zaman, method, path+query ve body'nin SHA-256'sı; biri değişirse imza tutmaz
public static string Canonical(long timestamp, string method, string pathAndQuery, byte[] body) =>
    $"{timestamp}\n{method}\n{pathAndQuery}\n{Convert.ToHexStringLower(SHA256.HashData(body))}";

public static string Sign(byte[] secret, string canonical) =>
    "v1=" + Convert.ToHexStringLower(HMACSHA256.HashData(secret, Encoding.UTF8.GetBytes(canonical)));

HMAC için iki tarafın da bildiği bir secret gerekiyor ve burada bir gerilim var: API key'i yalnız hash olarak saklıyoruz, yani sunucu key'in kendisini bilmiyor ve onu HMAC secret'ı olarak kullanamaz. Çözümüm, her key için ayrı bir imza secret'ını sunucudaki tek bir master key'den türetmek: HMACSHA256(master, "signing:" + keyId). Secret hiçbir yerde saklanmıyor, gerektiğinde yeniden hesaplanıyor ve key üretilirken bir kez istemciye veriliyor. Bedeli, master key'in çok değerli hâle gelmesi; production'da bu anahtar bir KMS (Key Management Service) ya da Key Vault içinde durmalı.

Doğrulama bir endpoint filter. Body'yi parametre olarak bağlamayan bir endpoint'e takılıyor, çünkü imza doğrulanmadan body'nin yorumlanmasını istemiyoruz:

public sealed class RequestSignatureFilter(SigningKeys keys, TimeProvider time, IMemoryCache seen,
    ILogger<RequestSignatureFilter> logger) : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
    {
        HttpRequest request = context.HttpContext.Request;
        string? keyId = context.HttpContext.User.FindFirstValue("key_id");
        string signature = request.Headers[RequestSignature.SignatureHeader].ToString();
        if (keyId is null || signature.Length == 0
            || !long.TryParse(request.Headers[RequestSignature.TimestampHeader], out long timestamp))
        {
            return Reject("missing signature headers");
        }

        // Saat farkına tolerans var ama pencere dışındaki istek tekrar oynatılmış sayılır
        DateTimeOffset sentAt = DateTimeOffset.FromUnixTimeSeconds(timestamp);
        if ((time.GetUtcNow() - sentAt).Duration() > RequestSignature.Window)
        {
            return Reject("timestamp outside the window");
        }

        request.EnableBuffering();
        using MemoryStream buffer = new();
        await request.Body.CopyToAsync(buffer, context.HttpContext.RequestAborted);
        request.Body.Position = 0;

        string canonical = RequestSignature.Canonical(timestamp, request.Method,
            request.Path + request.QueryString, buffer.ToArray());
        string expected = RequestSignature.Sign(keys.For(keyId), canonical);
        if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(signature)))
        {
            return Reject("signature mismatch");
        }

        // Pencere içinde aynı imza ikinci kez gelirse replay
        if (!seen.TryGetValue(signature, out _))
        {
            seen.Set(signature, true, RequestSignature.Window * 2);
            return await next(context);
        }
        return Reject("replayed signature");

        IResult Reject(string reason)
        {
            logger.LogWarning("Rejected signed request from key id {KeyId}: {Reason}", keyId, reason);
            return Results.Problem(statusCode: StatusCodes.Status401Unauthorized, title: "Invalid request signature.");
        }
    }
}

Timestamp penceresi beş dakika: istemci ve sunucu saatleri arasındaki küçük farkları tolere ediyor, ama dünkü bir isteğin tekrar oynatılmasını engelliyor. Pencere içindeki replay'i ise görülen imzaları pencerenin iki katı süre tutan bir cache yakalıyor. Lab'daki TokenTool'a imza üreten bir komut ekledim; gerçek bir istemci aynı dört satırı kendi dilinde yazar:

$ dotnet run --project tools/Bookstore.Orders.TokenTool -- sign $SECRET POST /integrations/orders/0197f001-0000-7000-8000-000000000001/shipments shipment.json > sig.txt
$ cat sig.txt
X-Bks-Timestamp: 1794116500
X-Bks-Signature: v1=48b6636879f8d93f661d6894a366a3dd06c3e68746918c1aed58c8da3801f80e
$ curl -i -X POST http://localhost:19513/integrations/orders/0197f001-0000-7000-8000-000000000001/shipments -H "Authorization: ApiKey $KEY" -H @sig.txt -H "Content-Type: application/json" --data-binary @shipment.json
HTTP/1.1 204 No Content
Date: Sun, 08 Nov 2026 05:41:39 GMT
Server: Kestrel

$ curl -i -X POST http://localhost:19513/integrations/orders/0197f001-0000-7000-8000-000000000001/shipments -H "Authorization: ApiKey $KEY" -H @sig.txt -H "Content-Type: application/json" --data-binary @shipment.json
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
Date: Sun, 08 Nov 2026 05:41:39 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Invalid request signature.","status":401,"traceId":"00-1198a42818962c7a92866af0b4a17350-20ff6efc86755795-00"}
warn: Bookstore.Orders.Api.Integrations.RequestSignatureFilter[0]
      Rejected signed request from key id 6lu2rqhriac2: replayed signature

İstemciye dönen mesaj sebebi söylemiyor; log söylüyor. Değiştirilmiş body ve eski timestamp senaryoları da testlerde: Tampered_body_and_stale_timestamp_are_rejected.

Kimlik Doğrulama Endpoint'lerini Sertleştirmek

OWASP API2, parola sıfırlama gibi endpoint'lerin login kadar korunmasını istiyor; çoğu ekip login'e lockout koyup "şifremi unuttum"u unutuyor. Oysa sıfırlama endpoint'i iki şey için kötüye kullanılabilir: kullanıcıların e-postalarına spam basmak ve hangi e-postaların sistemde kayıtlı olduğunu öğrenmek. İki savunmayı birlikte kuruyoruz: IP (Internet Protocol) adresi başına ve hesap başına limit, bir de hesap var olsun olmasın aynı yanıt.

// Hesap başına limit: aynı e-postaya 15 dakikada en fazla 3 sıfırlama isteği
private static readonly PartitionedRateLimiter<string> PerAccount = PartitionedRateLimiter.Create<string, string>(
    email => RateLimitPartition.GetFixedWindowLimiter(email, _ => new FixedWindowRateLimiterOptions
    {
        PermitLimit = 3,
        Window = TimeSpan.FromMinutes(15),
        QueueLimit = 0
    }));

public static void MapAccountEndpoints(this IEndpointRouteBuilder app)
{
    app.MapPost("/account/password-reset", (PasswordResetRequest request, HttpContext context,
        ILogger<PasswordResetRequest> logger) =>
    {
        string account = request.Email.Trim().ToLowerInvariant();
        using RateLimitLease lease = PerAccount.AttemptAcquire(account);
        if (!lease.IsAcquired)
        {
            if (lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan retryAfter))
            {
                context.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
            }
            return Results.Problem(statusCode: StatusCodes.Status429TooManyRequests,
                title: "Too many reset requests for this account.");
        }

        // Hesap var da olsa yok da olsa aynı yanıt: e-posta listesi doğrulanamaz
        logger.LogInformation("Password reset requested");
        return Results.Accepted();
    })
    .AllowAnonymous()
    .RequireRateLimiting(PerIpPolicy);
}

IP limiti AddRateLimiter içinde, RemoteIpAddress'e göre bölünmüş bir fixed window policy: dakikada 10 istek. Hesap limiti ise endpoint'in içinde, çünkü partition key'i body'deki e-posta ve rate limiting middleware'i body'yi okumaz. İki limit birbirini tamamlıyor: IP limiti tek kaynaktan gelen taramayı yavaşlatır, hesap limiti ise binlerce IP'ye dağıtılmış bir saldırının tek bir kullanıcıyı e-postaya boğmasını engeller. Middleware'in algoritmalarını ve chained limiter'ları ilerideki ASP.NET Core Güvenliği serisinin rate limiting yazısına bırakıyorum.

$ for i in 1 2 3; do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:19513/account/password-reset -H "Content-Type: application/json" -d '{"email":"alan@north.bookstore.test"}'; done
202 202 202
$ curl -i -X POST http://localhost:19513/account/password-reset -H "Content-Type: application/json" -d '{"email":"alan@north.bookstore.test"}'
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Date: Sun, 08 Nov 2026 05:41:39 GMT
Server: Kestrel
Retry-After: 900
Transfer-Encoding: chunked

{"title":"Too many reset requests for this account.","status":429,"traceId":"00-7a87cb9ca74117809b0d5b0fd0bff274-c74851f543b45b61-00"}
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:19513/account/password-reset -H "Content-Type: application/json" -d '{"email":"nobody@north.bookstore.test"}'
202

Var olmayan bir e-posta da 202 alıyor. Hesap limiti de var olan ve olmayan her adres için aynı çalıştığından 429 da bir şey ele vermiyor. Aynı iki katmanlı limit, varsa bir token endpoint'ine de uygulanmalı: client başına ve IP başına.

Bu yazının bütün iddiaları bir test class'ında. Matrise de altı satır ekledim: integration endpoint'ine anonim, müşteri JWT'si ve admin JWT'siyle gelen istekler 401 almalı; key yönetimi yalnız admin'e açık olmalı.

passed ApiKeyTests.Missing_scope_is_forbidden (563ms)
passed ApiKeyTests.Tampered_body_and_stale_timestamp_are_rejected (5ms)
passed ApiKeyTests.Only_the_hash_of_the_secret_is_stored (5ms)
passed ApiKeyTests.Third_active_key_for_the_same_client_is_a_conflict (1ms)
passed ApiKeyTests.Password_reset_is_limited_per_account_and_does_not_reveal_accounts (3ms)
passed ApiKeyTests.Key_sees_only_its_own_tenant (4ms)
passed ApiKeyTests.Revoked_key_is_rejected (2ms)
passed ApiKeyTests.Key_in_query_string_is_rejected_even_if_valid (3ms)
passed ApiKeyTests.Signed_shipment_is_accepted_once_and_replay_is_rejected (3ms)
Test run summary: Passed!
  total: 56
  failed: 0
  succeeded: 56
  skipped: 0
  duration: 1s 211ms

Üretimde Dikkat

Replay cache'i IMemoryCache, yani tek instance'a özel. Üç instance arkasında çalışan bir API'de aynı imza farklı instance'lara düşerek pencere içinde üç kez kabul edilebilir. Production'da görülen imzalar Redis gibi paylaşılan bir store'da, pencere süresi kadar TTL (Time To Live) ile tutulmalı. Alternatif olarak imzaya istemcinin ürettiği bir nonce ekleyip nonce'u veritabanında unique constraint ile saklamak da olur.

Master key'in rotation'ı ayrı bir konu. Türetilmiş secret'lar master key'e bağlı olduğu için master key'i değiştirmek bütün istemcilerin imza secret'ını değiştirir. Bunu yönetilebilir kılmak için imza header'ındaki v1= sürüm ön ekini master key sürümüne bağlayabilir ve bir süre iki master key'i birlikte kabul edebilirsin. İmzanın baştan bir sürüm alanı taşımasının sebebi bu.

Key sızıntısı olduğunda ne yapacağını önceden yaz. Prefix'i GitHub secret scanning'in özel pattern'lerine ya da kendi DLP (Data Loss Prevention) aracına tanımla; sızan bir key'i dakikalar içinde revoke etmek, hiç sızmamasını beklemekten daha gerçekçi bir hedef.

Integration isteklerini kullanıcı isteklerinden ayrı izle. key_id claim'i log scope'una girerse "ParcelCo'nun key'i dün gece 03:00'te 4000 istek attı" gibi bir soruyu cevaplayabilirsin. Kimlik doğrulama hatalarında keyId'nin yanına kaynak IP'yi yazmak da tarama girişimlerini görünür kılar.

Sık Düşülen Tuzaklar

Birinci tuzak, key'i şifreli saklamayı yeterli sanmak. Şifreli saklanan bir key, şifreleme anahtarına erişen herkes için düz metindir. Doğrulama için key'in kendisine değil, hash'ine ihtiyacın var.

İkincisi, key'i kullanıcının kimliği gibi kullanmak. "Key'i Alan'a verdik, yani bu istek Alan'ın" düşüncesi kişisel API token'larıyla karışıyor. Kişisel token gerekiyorsa kullanıcıya bağlı, kısa ömürlü ve scope'lu bir token üret; entegrasyon key'i ise bir client'ı temsil etsin.

Üçüncüsü, tek bir "full access" scope'u. Kargo firmasının siparişleri okumaya ve gönderi bildirmeye ihtiyacı var; iptal etmeye ya da müşteri listesini çekmeye değil. Scope'lar endpoint'lere değil, iş yeteneklerine göre tasarlanmalı ve key üretilirken allowlist'ten seçilmeli.

Dördüncüsü, key'i ya da imzayı log'a yazmak. Authorization header'ını request logging'e açmak, HTTP (Hypertext Transfer Protocol) client tarafında hata mesajına bütün isteği basmak, exception mesajında key'i taşımak; hepsi gerçek olaylarda görülmüş sızıntı yolları.

Küçük Bir Deneme

★☆☆ Lab'da ikinci bir key üret, iki key'le de /integrations/orders'ı çağır, sonra üçüncüyü dene. 409'u gördükten sonra eski key'i revoke edip yenisini üret.

★★☆ İmzaya X-Bks-Nonce header'ı ekle ve replay korumasını imza yerine keyId + nonce çiftine bağla. Aynı nonce ile farklı bir body gönderildiğinde ne olmalı?

★★★ LastUsedAt güncellemesini dakikada bir ile sınırla ve 30 gündür kullanılmayan key'leri listeleyen bir admin endpoint'i ekle. Bu endpoint'in matris satırlarını yazmayı unutma.

Bir sonraki yazıda API3'e (Broken Object Property Level Authorization) geçiyoruz: aynı siparişin support'a maliyet alanıyla, müşteriye maliyetsiz görünmesi, PATCH isteklerinde yazılabilir alan allowlist'i ve OpenAPI şemasını bir sözleşme olarak kullanmak.