12 KiB
Guide 15: API Key Authentication for Machine-to-Machine
What is API Key Authentication?
Not every system that calls your API has a human user who can type a username and password. Some callers are machines — a ward gateway device reporting vital signs, a FHIR integration engine sending patient data from the hospital's EHR (Electronic Health Record). These systems need to authenticate without a login form.
An API key is a pre-shared secret — a long random string that both the client and server know. The client sends it in an HTTP header, and the server checks if it matches. Think of it like a password, but for machines instead of humans.
Client (ward gateway) Server (API)
┌─────────────────────┐ ┌──────────────┐
│ Sends request with: │ │ Checks: │
│ X-Api-Key: dev-gw.. │ ─────────────────────► │ Does the key │
│ X-Gateway-Id: 222.. │ │ match config?│
└─────────────────────┘ └──────────────┘
Why not use JWT for machines too? You could, but JWT adds complexity that machines don't need. JWT involves a login step (exchanging credentials for a token), token expiration, and token refresh. API keys are simpler — one secret, no expiration logic, no login endpoint. The tradeoff: API keys have no built-in expiration, so key rotation must be handled manually.
Two API Key Systems in This Project
This project has two independent API key systems for different purposes:
| System | Header | Who Uses It | What It Protects |
|---|---|---|---|
| Gateway API key | X-Api-Key + X-Gateway-Id |
Ward gateway devices | Gateway-specific endpoints (heartbeat, sync upload) |
| FHIR API key | X-Api-Key |
Hospital integration engines (like Mirth Connect) | FHIR R4 endpoints (/fhir/R4/*) |
Both use the same header name (X-Api-Key) but are handled by different authentication components and configured with different keys.
System 1: Gateway API Key Authentication
The Authentication Handler
When a ward gateway sends a request with X-Api-Key, the GatewayApiKeyAuthenticationHandler validates it:
public sealed class GatewayApiKeyAuthenticationHandler
: AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "GatewayApiKey";
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
// 1. Check if the request has an X-Api-Key header
if (!Request.Headers.TryGetValue("X-Api-Key", out var suppliedHeader))
return Task.FromResult(AuthenticateResult.NoResult());
// NoResult means "I can't handle this — let another scheme try"
// 2. Load the configured key from appsettings
var configured = _config["ApiKey:Gateway"];
if (string.IsNullOrEmpty(configured))
return Task.FromResult(AuthenticateResult.Fail(
"Gateway API key not configured."));
// 3. Compare using constant-time comparison
if (!FixedTimeEquals(suppliedHeader.ToString(), configured))
return Task.FromResult(AuthenticateResult.Fail("Invalid API key."));
// 4. Create claims for the authenticated gateway
var claims = new List<Claim> { new("client_type", "gateway") };
if (Request.Headers.TryGetValue("X-Gateway-Id", out var gatewayIdHeader)
&& Guid.TryParse(gatewayIdHeader.ToString(), out _))
claims.Add(new Claim("gateway_id", gatewayIdHeader.ToString()!));
// 5. Return a successful authentication result
var identity = new ClaimsIdentity(claims, SchemeName);
var ticket = new AuthenticationTicket(
new ClaimsPrincipal(identity), SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
Constant-Time Comparison
private static bool FixedTimeEquals(string supplied, string configured)
{
var suppliedBytes = Encoding.UTF8.GetBytes(supplied);
var configuredBytes = Encoding.UTF8.GetBytes(configured);
return CryptographicOperations.FixedTimeEquals(suppliedBytes, configuredBytes);
}
Why not just use == to compare strings? Regular string comparison (==) is vulnerable to timing attacks. When comparing two strings character by character, the comparison fails faster when the first character is wrong than when the last character is wrong. An attacker can measure this timing difference (even over a network) and deduce the key one character at a time.
CryptographicOperations.FixedTimeEquals always takes the same amount of time regardless of where the mismatch occurs. It compares every byte even after finding a difference, so the timing reveals nothing about which bytes matched.
This matters in practice: timing attacks have been demonstrated against real APIs over the public internet with as few as ~1000 requests per character.
Registration
The gateway scheme is registered alongside JWT bearer as a secondary authentication scheme:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => { /* JWT config */ })
.AddScheme<AuthenticationSchemeOptions, GatewayApiKeyAuthenticationHandler>(
GatewayApiKeyAuthenticationHandler.SchemeName, null);
How does ASP.NET Core choose which scheme to use? JWT Bearer is the default scheme (first argument to AddAuthentication). For most requests, ASP.NET tries JWT first. The gateway handler returns NoResult() when there's no X-Api-Key header, signaling "this isn't my request." For gateway endpoints that specifically require the GatewayApiKey scheme, controllers can specify:
[Authorize(AuthenticationSchemes = GatewayApiKeyAuthenticationHandler.SchemeName)]
Configuration
{
"ApiKey": {
"Gateway": "dev-gateway-key-change-in-production"
}
}
System 2: FHIR API Key (Middleware-Based)
FHIR endpoints accept either a JWT token OR an API key. This is implemented as middleware (not an authentication handler) because the dual-auth logic needs to run before the standard authentication pipeline:
public class FhirApiKeyOrJwtMiddleware
{
public async Task InvokeAsync(HttpContext context)
{
// Only applies to /fhir/* paths
if (!context.Request.Path.StartsWithSegments("/fhir"))
{
await _next(context);
return;
}
// /fhir/R4/metadata is always public (FHIR standard)
if (context.Request.Path.StartsWithSegments("/fhir/R4/metadata"))
{
await _next(context);
return;
}
// If already authenticated via JWT, let it through
if (context.User.Identity?.IsAuthenticated == true)
{
await _next(context);
return;
}
// Try API key authentication
var configuredKeys = GetConfiguredKeys();
if (context.Request.Headers.TryGetValue("X-Api-Key", out var suppliedKey)
&& MatchesAnyKey(suppliedKey!, configuredKeys))
{
// Set up an Integration user identity
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "44444444-..."),
new Claim(ClaimTypes.Name, "integration.mirth"),
new Claim("display_name", "Mirth Connect"),
new Claim("clinical_role", ClinicalRole.Integration.ToDbString()),
};
context.User = new ClaimsPrincipal(
new ClaimsIdentity(claims, "FhirApiKey"));
await _next(context);
return;
}
// X-Api-Key was provided but didn't match — return 401
if (context.Request.Headers.ContainsKey("X-Api-Key"))
{
context.Response.StatusCode = 401;
// Return FHIR OperationOutcome error format
return;
}
// No API key — fall through to normal JWT auth
await _next(context);
}
}
Why set clinical_role to Integration? When the API key authenticates successfully, the middleware creates a fake user identity with the Integration role. This means the RBAC system (Guide 14) works exactly the same way — the FHIR integration user has Integration permissions (can write patients, encounters, observations, but can't acknowledge alerts or modify thresholds).
Key Rotation Support
The FHIR system supports multiple active keys simultaneously for zero-downtime rotation:
public class FhirOptions
{
public string? ApiKey { get; set; } // single key (convenience)
public string[] ApiKeys { get; set; } = []; // multiple keys for rotation
}
{
"Fhir": {
"ApiKey": "current-key-abc",
"ApiKeys": ["current-key-abc", "new-key-xyz"]
}
}
How does key rotation work?
- Add the new key to
ApiKeysalongside the old key → deploy - Update the integration system to use the new key
- Remove the old key from
ApiKeys→ deploy
During step 1-2, both keys are valid. The integration system experiences zero downtime.
The key matching checks all configured keys using constant-time comparison:
private static bool MatchesAnyKey(string supplied, string[] configuredKeys)
{
var suppliedBytes = Encoding.UTF8.GetBytes(supplied);
var matched = false;
foreach (var configured in configuredKeys)
{
var configuredBytes = Encoding.UTF8.GetBytes(configured);
if (CryptographicOperations.FixedTimeEquals(suppliedBytes, configuredBytes))
matched = true;
// Don't return early — check all keys to prevent timing leaks
}
return matched;
}
Note that it checks ALL keys even after finding a match. If it returned immediately on the first match, an attacker could determine how many keys are configured by measuring response time.
How the Two Systems Interact
The middleware pipeline processes requests in order:
app.UseMiddleware<CorrelationIdMiddleware>(); // 1. Add correlation ID
app.UseMiddleware<FhirApiKeyOrJwtMiddleware>(); // 2. FHIR API key (sets User if matched)
app.UseMiddleware<ExceptionHandlerMiddleware>(); // 3. Error handling
app.UseAuthentication(); // 4. JWT / GatewayApiKey handlers
app.UseAuthorization(); // 5. Permission checks
For a FHIR request with an API key:
- Step 2 matches the key and sets
context.Userwith Integration claims - Step 4 sees that
Useris already authenticated and skips JWT validation - Step 5 checks the Integration role's permissions normally
For a gateway request with an API key:
- Step 2 doesn't match (
/api/gateways/...doesn't start with/fhir) - Step 4 runs the
GatewayApiKeyAuthenticationHandler, which validates the key - Step 5 checks authorization normally
For a dashboard request with a JWT:
- Step 2 doesn't match (no API key header)
- Step 4 validates the JWT token
- Step 5 checks the user's role permissions
Key Takeaways
- API keys are for machines, JWTs are for humans — API keys are simpler (no login step, no expiration logic) but require manual rotation
- Always use constant-time comparison for secrets —
CryptographicOperations.FixedTimeEqualsprevents timing attacks that could leak the key character by character - Dual auth (key OR token) gives flexibility — FHIR endpoints accept either, so both integration engines (API key) and admin users (JWT) can access them
- Key rotation requires supporting multiple keys simultaneously — add the new key first, migrate clients, then remove the old key
- API key authentication creates a claims identity — the
Integrationrole feeds into the same RBAC system as JWT-authenticated users, so permissions are managed in one place - Return
NoResult()for unrecognized requests — this tells ASP.NET "try the next authentication scheme" rather than failing immediately, enabling multiple schemes to coexist