API Keys with a Custom Handler

Authenticate partner integrations with API keys in an AuthenticationHandler, storing only hashes, comparing in constant time and rotating safely.

Syntaxbuilder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddScheme<ApiKeyOptions, ApiKeyHandler>("ApiKey", o => o.HeaderName = "X-Api-Key"); policy.AddAuthenticationSchemes("ApiKey").RequireAuthenticatedUser();

API keys suit server-to-server integrations where the caller cannot run an OAuth flow: a partner's ERP pulling stock levels, a webhook sender, a script. A key is a long random secret that identifies a client. Done properly it is a small authentication scheme: subclass AuthenticationHandler<TOptions>, register it with AddScheme, and authorization works exactly as it does for JWTs.

Why a handler, not middleware or a filter

A handler plugs into the framework: it produces a ClaimsPrincipal, so policies, [Authorize], rate limiting by user and logging scopes all see the client; challenge returns a proper 401 with WWW-Authenticate; and you can combine it with bearer tokens per endpoint. Return NoResult() when the header is absent (other schemes may still apply) and Fail(...) when a key is present but wrong.

Storing and checking keys

  • Format: sk_live_{id}_{secret}. The public id finds the row in one indexed lookup; the prefix makes leaked keys easy to detect by secret scanners.
  • Store a hash only: SHA-256 of the full key. A 256-bit random secret cannot be brute-forced, so a slow password hash such as PBKDF2 adds latency on every request without adding safety. Show the key once at creation.
  • Compare in constant time: CryptographicOperations.FixedTimeEquals on the two hashes. == on strings stops at the first differing character, which leaks timing.
  • Header, never query string: URLs end up in proxy logs, browser history and analytics.

Rotation

Allow several active keys per client. Rotation is: issue a new key, the partner deploys it, the old key gets an expiry a week out, then it is revoked (revoked_at set; the row stays for audit). Record LastUsedAt per key so you can see whether the old one is still in use before it expires, and put the key id (never the secret) in logs and claims so incidents can be traced to a key.

ConcernAPI keyOAuth client credentials
Caller effortOne headerToken request and caching
LifetimeLong-lived until revokedMinutes; secret stays at the provider
RevocationSet revoked_at (cache TTL applies)Disable client; tokens live until expiry
Best forExternal partners, webhooks, simple scriptsYour own services, enterprise partners

Failure modes

Plaintext keys in the database, leaked by a backup; keys logged by request logging middleware that records all headers; a lookup cached for an hour so a revoked key keeps working; and no per-key rate limit, so one partner's runaway loop takes the API down for everyone.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Let a partner's ERP read /partner/stock/{sku} with an X-Api-Key header while staff endpoints keep requiring JWTs.
  • Rotate a partner's key: issue a new one, watch last_used_at on the old one, and expire it a week later without downtime.
  • Trace a burst of 429s to one partner key id in the logs without the secret ever appearing in a log line.

More examples

The keys table

The primary key is the public id embedded in the key, so authentication is one indexed lookup followed by a hash comparison. Update <code>last_used_at</code> asynchronously or at most once a minute per key; writing on every request turns a read path into a hot write path. A revoked key is marked, not deleted, so audit queries can still explain which key made a past call.

Example · sql
CREATE TABLE api_keys (
    id            char(12)     PRIMARY KEY,          -- public part of the key, safe to log
    client_id     text         NOT NULL REFERENCES api_clients (id),
    key_hash      bytea        NOT NULL,             -- SHA-256 of the full key; the key itself is never stored
    scopes        text[]       NOT NULL,
    created_at    timestamptz  NOT NULL DEFAULT now(),
    expires_at    timestamptz  NULL,                 -- set a week out when rotating
    last_used_at  timestamptz  NULL,
    revoked_at    timestamptz  NULL
);

CREATE INDEX api_keys_client_idx ON api_keys (client_id) WHERE revoked_at IS NULL;

-- Rotation: is the old key still being used?
SELECT id, last_used_at, expires_at
FROM api_keys
WHERE client_id = 'partner-acme' AND revoked_at IS NULL
ORDER BY created_at;

Caching lookups without making revocation slow

The cache TTL is the maximum time a revoked key keeps working on an instance that does not get the eviction, so keep it to a minute and evict explicitly on revoke in the instance that handled it. Caching misses briefly protects the database from a scanner sending random ids. The cached record holds only the hash, so a memory dump does not reveal usable keys.

Example · csharp
using Microsoft.Extensions.Caching.Memory;

public sealed record ApiKeyRecord(string Id, byte[] Hash, string ClientId, string[] Scopes, DateTimeOffset? ExpiresAt, DateTimeOffset? RevokedAt);

public interface IApiKeyStore
{
    ValueTask<ApiKeyRecord?> FindAsync(string keyId, CancellationToken cancellationToken);
}

// Registered as a decorator: services.AddSingleton<IApiKeyStore>(sp => new CachedApiKeyStore(inner, cache)).
public sealed class CachedApiKeyStore(IApiKeyStore inner, IMemoryCache cache) : IApiKeyStore
{
    private static readonly TimeSpan Found = TimeSpan.FromSeconds(60);
    private static readonly TimeSpan Missing = TimeSpan.FromSeconds(10);

    public async ValueTask<ApiKeyRecord?> FindAsync(string keyId, CancellationToken cancellationToken)
    {
        if (cache.TryGetValue(CacheKey(keyId), out ApiKeyRecord? cached))
            return cached;

        var record = await inner.FindAsync(keyId, cancellationToken);
        // Cache misses too, briefly, so random ids cannot hammer the database.
        cache.Set(CacheKey(keyId), record, record is null ? Missing : Found);
        return record;
    }

    public void Evict(string keyId) => cache.Remove(CacheKey(keyId));

    private static string CacheKey(string keyId) => $"api-key:{keyId}";
}

Rate limiting per key, not per IP

Partners often run from a few shared egress IPs, so an IP limit punishes everyone behind the same NAT while letting one key spread across many IPs. Partitioning on the <code>key_id</code> claim gives each key its own bucket. The rate limiter must come after <code>UseAuthentication</code>, or every request lands in the <code>anonymous</code> partition and one partner's loop throttles all of them.

Example · csharp
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

// The ApiKey scheme is what puts key_id on the principal; without it every request is "anonymous".
builder.Services
    .AddAuthentication(ApiKeyHandler.SchemeName)
    .AddScheme<ApiKeyOptions, ApiKeyHandler>(ApiKeyHandler.SchemeName, o => o.HeaderName = "X-Api-Key");
builder.Services.AddAuthorization();

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddPolicy("per-partner", context =>
    {
        // Runs after authentication, so the partner identity is available.
        var keyId = context.User.FindFirstValue("key_id") ?? "anonymous";
        return RateLimitPartition.GetTokenBucketLimiter(keyId, _ => new TokenBucketRateLimiterOptions
        {
            TokenLimit = 100,
            TokensPerPeriod = 50,
            ReplenishmentPeriod = TimeSpan.FromSeconds(1),
            QueueLimit = 0,
        });
    });
});

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter(); // after authentication: the partition key comes from the principal

app.MapGet("/partner/stock/{sku}", (string sku) => TypedResults.Ok(new { sku, available = 42 }))
    .RequireAuthorization()
    .RequireRateLimiting("per-partner");

app.Run();

sealed class ApiKeyOptions : AuthenticationSchemeOptions
{
    public string HeaderName { get; set; } = "X-Api-Key";
}

// Wiring stand-in: the real handler (main example) checks the hash, expiry and revocation
// before it issues these claims. This one only shows where key_id comes from.
sealed class ApiKeyHandler(IOptionsMonitor<ApiKeyOptions> options, ILoggerFactory logger, UrlEncoder encoder)
    : AuthenticationHandler<ApiKeyOptions>(options, logger, encoder)
{
    public const string SchemeName = "ApiKey";

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue(Options.HeaderName, out var header))
            return Task.FromResult(AuthenticateResult.NoResult());
        var key = header.ToString(); // sk_live_{12-char id}_{secret}
        if (!key.StartsWith("sk_live_", StringComparison.Ordinal) || key.Length < 22 || key[20] != '_')
            return Task.FromResult(AuthenticateResult.Fail("Malformed API key"));
        var identity = new ClaimsIdentity([new Claim("key_id", key.Substring(8, 12))], Scheme.Name);
        return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)));
    }
}

Discussion

  • Be the first to comment on this lesson.