Skip to main content
SEMastery
ASP.NETintermediate

API Key Authentication in ASP.NET Core: The Secure Way

Learn how to add API key authentication to your ASP.NET Core API the right way. Use an AuthenticationHandler, hash keys, compare safely, and follow 2026 security best practices, with diagrams and code.

11 min readUpdated December 8, 2025

A membership card at the gym

Think about how a gym works. To get in, you show your membership card at the front desk. The staff scan it and check their system: is this a real, active member? If yes, you walk in. If the card is fake, expired, or you forgot it, you are politely turned away.

An API key is exactly that membership card for your web API. When another program wants to use your API, it sends a secret key with each request. Your server checks: is this key real and active? If yes, the request continues. If not, it is rejected.

API keys are a simple, popular way to protect server-to-server APIs and developer integrations. But "simple" does not mean "careless" — there is a right way and a wrong way to do it. Let us build it the secure way, following 2026 best practices.

How API key authentication works

The flow is short. The client sends the key in a header, and the server validates it before doing any work.

Figure 1: The client sends an API key in a header. The server validates it and either allows the request or returns 401.

The key always travels in a header, by convention named X-API-Key. Never put it in the URL query string — URLs get logged in server logs, browser history, and proxies, which would leak your secret everywhere.

Do it the right way: an AuthenticationHandler

Many old tutorials show API key checks using middleware. In 2026, that is discouraged for new code. Middleware-only checks bypass the ASP.NET Core authentication pipeline, do not compose with [Authorize], and force you to reinvent things the framework already does well.

The recommended approach is a custom AuthenticationHandler. It plugs into the real auth pipeline, so it works with [Authorize], authorization policies, and OpenAPI.

Why AuthenticationHandler Beats Middleware

Request
AuthenticationHandler
Validates key
Sets ClaimsPrincipal
[Authorize] works

Steps

1

Request

A call arrives with an X-API-Key header

2

Handler

A custom AuthenticationHandler runs in the auth pipeline

3

Validate

It checks the key against stored hashes

4

Identity

On success it builds a ClaimsPrincipal (who the caller is)

5

Authorize

[Authorize] and policies now work normally

A handler integrates with the whole ASP.NET Core auth system; middleware sits outside it and reinvents the wheel.

Here is a compact handler:

public class ApiKeyAuthHandler(
    IOptionsMonitor<AuthenticationSchemeOptions> options,
    ILoggerFactory logger,
    UrlEncoder encoder,
    IApiKeyValidator validator)
    : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        // 1. Read the key from the header
        if (!Request.Headers.TryGetValue("X-API-Key", out var keyValues))
            return AuthenticateResult.NoResult(); // no key — let it fall through
 
        var apiKey = keyValues.ToString();
 
        // 2. Validate it (hashed lookup, see below)
        var client = await validator.Validate(apiKey);
        if (client is null)
            return AuthenticateResult.Fail("Invalid API key");
 
        // 3. Build the identity for this caller
        var claims = new[] { new Claim(ClaimTypes.Name, client.Name) };
        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        return AuthenticateResult.Success(
            new AuthenticationTicket(principal, Scheme.Name));
    }
}

Register it like any other authentication scheme:

builder.Services
    .AddAuthentication("ApiKey")
    .AddScheme<AuthenticationSchemeOptions, ApiKeyAuthHandler>("ApiKey", null);
 
builder.Services.AddAuthorization();
 
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
 
app.MapGet("/data", () => "secret data").RequireAuthorization();

Now [Authorize] and RequireAuthorization() just work, because the handler is part of the real pipeline.

The security part: never store raw keys

This is the most important section. Never store API keys as plain text in your database. If your database leaks, every key is exposed. Instead, store only a hash of the key — a one-way fingerprint.

Because API keys are long, random, high-entropy strings, you should hash them with SHA-256. (Unlike passwords, you do not need slow hashes like Argon2id here — those would only slow validation down without adding real security for random keys.)

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

When validating, hash the incoming key and look up the hash:

public class ApiKeyValidator(AppDbContext db) : IApiKeyValidator
{
    public async Task<ApiClient?> Validate(string apiKey)
    {
        var hash = HashKey(apiKey);
        var client = await db.ApiClients
            .FirstOrDefaultAsync(c => c.KeyHash == hash && c.IsActive);
        return client;
    }
}
🚨

Storing raw API keys is one of the most common and most dangerous mistakes. Treat a key like a password: hash it before saving, and never log the full key. If you must log something, log only a safe prefix like sk_live_….

Compare safely: avoid timing attacks

There is a subtle attack worth knowing. A normal string comparison (==) stops as soon as it finds a difference. An attacker can measure tiny timing differences to slowly guess a secret, character by character. On an internet-exposed API, this is a real risk.

The fix is a constant-time comparison that always takes the same time regardless of where the difference is:

var match = CryptographicOperations.FixedTimeEquals(
    Encoding.UTF8.GetBytes(incomingHash),
    Encoding.UTF8.GetBytes(storedHash));

Use CryptographicOperations.FixedTimeEquals whenever you compare secrets. It is a small change that closes a real hole.

Make it fast with caching

Hashing is quick, but a database lookup on every single request is wasteful for a busy API. Hot keys should not hit the database each time. Cache the validation result — an in-memory or HybridCache hit is thousands of times faster than a database round-trip:

public async Task<ApiClient?> Validate(string apiKey)
{
    var hash = HashKey(apiKey);
    return await cache.GetOrCreateAsync($"apikey:{hash}", async _ =>
        await db.ApiClients.FirstOrDefaultAsync(c => c.KeyHash == hash && c.IsActive));
}

Just remember to clear the cache when a key is revoked, so a cancelled key stops working promptly.

The validation steps, in order

When a request arrives, the handler runs a clear sequence of checks. Picturing it helps you build it correctly:

API Key Validation Steps

Read header
Hash the key
Look up hash
Compare safely
Build identity

Steps

1

Read

Get the key from the X-API-Key header

2

Hash

SHA-256 the incoming key

3

Look up

Find the active client by hash (cache first, then DB)

4

Compare

Use FixedTimeEquals to avoid timing attacks

5

Identity

Build a ClaimsPrincipal so [Authorize] works

Read the header, hash it, look up the hash (cached), compare safely, then build the caller's identity.

The life of a key: issue, rotate, revoke

An API key is not "set once and forget." A secure system manages a key through its whole life. Treat keys like keys to a house — sometimes you cut a new one, and sometimes you change the lock.

Figure 4: The lifecycle of an API key — issued, used many times, rotated to a fresh key, and revoked if leaked.

Three habits make this safe:

  • Rotation. Let clients create a new key and keep the old one working for a short overlap, so they can switch without downtime. Then retire the old key.
  • Revocation. If a key leaks, you must be able to cancel it instantly. Mark it inactive in the database and clear it from the cache so the next call fails.
  • Expiry. Optionally give keys an expiry date, forcing periodic rotation. Short-lived keys limit the damage of a leak.
💡

When you generate a key, show it to the user only once and store just the hash. If they lose it, they rotate to a new one — you can never show the original again, because you never kept it. This is the same principle as passwords.

401 vs 403: say the right thing

A well-behaved API distinguishes two different failures:

Figure 3: 401 means 'I do not know who you are' (bad or missing key). 403 means 'I know you, but you are not allowed' (valid key, missing permission).

Return these as ProblemDetails (RFC 9457) so clients get a clear, standard error body. The distinction matters: 401 tells the caller to fix their key; 403 tells them their key is fine but lacks the needed scope.

Best practices checklist

PracticeWhy
Send keys in a header, over HTTPSURLs get logged and leak secrets
Hash keys (SHA-256) before storingA DB leak must not expose raw keys
Use FixedTimeEquals to compareStops timing attacks
Use a prefix like sk_live_…Leaked keys are scannable; safe to log
Cache validation (HybridCache)Avoid a DB hit on every request
Support revocationCancel a leaked key instantly
Return 401 vs 403 correctlyClear, standard error handling

And a quick comparison of where API keys fit among auth methods:

MethodBest forNotes
API keyServer-to-server, integrationsSimple; identifies the app, not a user
JWT / OAuthUser logins, third-party accessRicher, supports user identity and scopes
Cookie authBrowser-based web appsBuilt for interactive sessions

Adding scopes: what a key is allowed to do

Authentication answers who is calling. Often you also want to control what they can do. You can attach scopes (permissions) to each key — for example, read:orders or write:orders — and store them alongside the key.

When you build the caller's identity in the handler, add the scopes as claims:

var claims = new List<Claim> { new(ClaimTypes.Name, client.Name) };
foreach (var scope in client.Scopes)        // e.g. "read:orders"
    claims.Add(new Claim("scope", scope));

Then protect endpoints with authorization policies that require a scope:

builder.Services.AddAuthorization(options =>
    options.AddPolicy("CanWriteOrders", p =>
        p.RequireClaim("scope", "write:orders")));
 
app.MapPost("/orders", CreateOrder).RequireAuthorization("CanWriteOrders");

Now a read-only key can list orders but cannot create them — and an attempt returns 403 Forbidden, not 401. This is how you give different partners different levels of access with the same simple key mechanism.

When to use API keys

API keys are perfect for server-to-server communication and developer integrations — when one program calls another and you mainly need to know which application is calling. They are simple to issue, easy to rotate, and need no login screen.

They are not ideal for representing individual users with rich permissions and sessions — for that, JWT or OAuth is a better fit. Many real systems use both: API keys for partner integrations, and JWT for user-facing apps.

Quick recap

  • An API key is like a gym membership card — a secret the client sends so your API knows who is calling.
  • Use a custom AuthenticationHandler, not middleware, so it works with [Authorize], policies, and OpenAPI.
  • Never store raw keys — hash them with SHA-256, and compare with FixedTimeEquals to stop timing attacks.
  • Send keys in the X-API-Key header over HTTPS, use a prefix, cache validation, and support revocation.
  • Return 401 for a bad/missing key and 403 for a valid key without permission.

Treat each API key like a precious membership card — check it properly, store only its fingerprint, and your API stays open to the right callers and closed to everyone else.

References and further reading

Related Posts