API keys are the simplest form of API authentication. They're not a replacement for OAuth or JWT — they identify the application making the request rather than the user. But for service-to-service communication, webhooks, and third-party integrations, API keys are practical and widely understood.

Where to Send the Key

There are three common locations for API keys. Each has trade-offs:

Header (recommended): X-Api-Key: abc123 — doesn't leak in server logs or browser history.

Query string: ?api_key=abc123 — easy to use but appears in logs, browser history, and referrer headers. Avoid for sensitive operations.

Authorization header: Authorization: ApiKey abc123 — follows the standard authorisation header pattern but uses a custom scheme.

Custom Authentication Handler

The cleanest approach is implementing a proper authentication handler that integrates with ASP.NET Core's authentication system:

ApiKeyAuthenticationHandler.cs
public class ApiKeyAuthenticationHandler
    : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
    private readonly IApiKeyStore _apiKeyStore;

    public ApiKeyAuthenticationHandler(
        IOptionsMonitor<ApiKeyAuthenticationOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        IApiKeyStore apiKeyStore)
        : base(options, logger, encoder)
    {
        _apiKeyStore = apiKeyStore;
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue(
            Options.HeaderName, out var headerValues))
        {
            return AuthenticateResult.NoResult();
        }

        var apiKey = headerValues.FirstOrDefault();

        if (string.IsNullOrEmpty(apiKey))
            return AuthenticateResult.NoResult();

        var keyInfo = await _apiKeyStore.ValidateKeyAsync(apiKey);

        if (keyInfo is null)
            return AuthenticateResult.Fail("Invalid API key");

        var claims = new List<Claim>
        {
            new(ClaimTypes.Name, keyInfo.ClientName),
            new("client_id", keyInfo.ClientId),
        };

        foreach (var scope in keyInfo.Scopes)
        {
            claims.Add(new Claim("scope", scope));
        }

        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);

        return AuthenticateResult.Success(ticket);
    }
}

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

Register it:

Program.cs
builder.Services.AddAuthentication("ApiKey")
    .AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
        "ApiKey", options =>
        {
            options.HeaderName = "X-Api-Key";
        });

builder.Services.AddSingleton<IApiKeyStore, DatabaseApiKeyStore>();

Storing API Keys Securely

Never store API keys in plain text. Hash them just as you would passwords — but use a fast hash since API keys are high-entropy random strings, not user-chosen passwords:

DatabaseApiKeyStore.cs
public class DatabaseApiKeyStore : IApiKeyStore
{
    private readonly AppDbContext _db;

    public DatabaseApiKeyStore(AppDbContext db) => _db = db;

    public static string GenerateApiKey()
    {
        var bytes = RandomNumberGenerator.GetBytes(32);
        return Convert.ToBase64String(bytes);
    }

    public static string HashKey(string apiKey)
    {
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(apiKey));
        return Convert.ToHexStringLower(bytes);
    }

    public async Task<ApiKeyInfo?> ValidateKeyAsync(string apiKey)
    {
        var hash = HashKey(apiKey);

        return await _db.ApiKeys
            .Where(k => k.KeyHash == hash
                     && k.IsActive
                     && (k.ExpiresAt == null || k.ExpiresAt > DateTime.UtcNow))
            .Select(k => new ApiKeyInfo
            {
                ClientId = k.ClientId,
                ClientName = k.ClientName,
                Scopes = k.Scopes
            })
            .FirstOrDefaultAsync();
    }
}

When you issue a key, show it to the user once and store only the hash. This way, a database breach doesn't expose usable API keys.

Endpoint Filter Approach

For simpler scenarios, an endpoint filter can validate API keys without a full authentication handler:

ApiKeyEndpointFilter.cs
public class ApiKeyEndpointFilter : IEndpointFilter
{
    private readonly IApiKeyStore _store;

    public ApiKeyEndpointFilter(IApiKeyStore store) => _store = store;

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        if (!context.HttpContext.Request.Headers
            .TryGetValue("X-Api-Key", out var key))
        {
            return Results.Unauthorized();
        }

        var keyInfo = await _store.ValidateKeyAsync(key.ToString());

        if (keyInfo is null)
            return Results.Unauthorized();

        context.HttpContext.Items["ApiKeyInfo"] = keyInfo;
        return await next(context);
    }
}

Apply it to specific endpoint groups:

Program.cs
var webhooks = app.MapGroup("/webhooks")
    .AddEndpointFilter<ApiKeyEndpointFilter>();

webhooks.MapPost("/stripe", (HttpContext context) =>
{
    var keyInfo = context.Items["ApiKeyInfo"] as ApiKeyInfo;
    return Results.Ok($"Received webhook from {keyInfo?.ClientName}");
});

Rate Limiting by API Key

API keys naturally provide an identity for rate limiting:

Program.cs
builder.Services.AddRateLimiter(options =>
{
    options.AddPolicy("api-key", context =>
    {
        var apiKey = context.Request.Headers["X-Api-Key"].ToString();

        return RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: apiKey,
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 100,
                Window = TimeSpan.FromMinutes(1)
            });
    });
});

Key Rotation

Support multiple active keys per client so they can rotate without downtime:

Program.cs
app.MapPost("/api/keys/rotate", async (
    IApiKeyStore store, ClaimsPrincipal user) =>
{
    var clientId = user.FindFirstValue("client_id")!;

    // Generate new key
    var newKey = DatabaseApiKeyStore.GenerateApiKey();
    await store.CreateKeyAsync(clientId, newKey);

    // Mark old keys for expiry (give clients time to switch)
    await store.ExpireOldKeysAsync(clientId, TimeSpan.FromHours(24));

    // Return the new key (only time it's shown in plain text)
    return Results.Ok(new { apiKey = newKey });
});

Wrapping Up

API keys are appropriate for identifying applications, not users. Hash them at rest, send them in headers rather than query strings, and implement rotation support from day one. For user-facing authentication, use OAuth or JWT instead — API keys lack the scoping, expiry, and revocation capabilities that user authentication demands.