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.
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.
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
Steps
Request
A call arrives with an X-API-Key header
Handler
A custom AuthenticationHandler runs in the auth pipeline
Validate
It checks the key against stored hashes
Identity
On success it builds a ClaimsPrincipal (who the caller is)
Authorize
[Authorize] and policies now work normally
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
Steps
Read
Get the key from the X-API-Key header
Hash
SHA-256 the incoming key
Look up
Find the active client by hash (cache first, then DB)
Compare
Use FixedTimeEquals to avoid timing attacks
Identity
Build a ClaimsPrincipal so [Authorize] works
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.
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:
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
| Practice | Why |
|---|---|
| Send keys in a header, over HTTPS | URLs get logged and leak secrets |
| Hash keys (SHA-256) before storing | A DB leak must not expose raw keys |
| Use FixedTimeEquals to compare | Stops 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 revocation | Cancel a leaked key instantly |
| Return 401 vs 403 correctly | Clear, standard error handling |
And a quick comparison of where API keys fit among auth methods:
| Method | Best for | Notes |
|---|---|---|
| API key | Server-to-server, integrations | Simple; identifies the app, not a user |
| JWT / OAuth | User logins, third-party access | Richer, supports user identity and scopes |
| Cookie auth | Browser-based web apps | Built 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
FixedTimeEqualsto stop timing attacks. - Send keys in the
X-API-Keyheader 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
- API Key Authentication in ASP.NET Core (.NET 10) — codewithmukesh — a thorough, modern guide.
- Implement API Key Authentication in ASP.NET Core — Code Maze — a clear, practical walkthrough.
- aspnetcore-authentication-apikey (GitHub) — a lightweight library that implements the handler approach for you.
Related Posts
Authentication and Authorization Best Practices in ASP.NET Core
A friendly guide to authentication and authorization in ASP.NET Core for .NET 10 — JWT, cookies, claims, roles, policies, and security best practices with diagrams.
Refresh Tokens and Token Revocation in ASP.NET Core: A Beginner Guide
Learn refresh tokens and token revocation in ASP.NET Core with simple words, diagrams, and code. Short access tokens, rotation, reuse detection, and safe logout.
Rate Limiting in ASP.NET Core: A Simple, Complete Guide
Learn rate limiting in ASP.NET Core with simple examples. Understand fixed window, sliding window, token bucket, and concurrency limiters, with diagrams, code, and real-world advice on which to pick.
Top 15 Mistakes Developers Make When Creating Web APIs
A warm, beginner-friendly tour of the 15 most common Web API mistakes in ASP.NET Core, with simple fixes, diagrams, tables, and clear C# examples.
CORS in ASP.NET Core: A Comprehensive Guide
A simple, friendly guide to CORS in ASP.NET Core. Learn how the browser, preflight requests, and policies work, with clear diagrams, tables, and code.
Building Secure APIs with Role-Based Access Control in ASP.NET Core
Learn role-based access control (RBAC) in ASP.NET Core. Add roles to JWT tokens, guard endpoints with policies, and return correct 401 and 403 codes, with diagrams and code.