Custom Authentication Handlers in ASP.NET Core
ASP.NET Core ships with authentication handlers for JWT bearers, cookies, OAuth, and OpenID Connect. But sometimes you need to authenticate using an API key, an HMAC signature, or a proprietary token format. Custom authentication handlers let you plug into the same authentication pipeline that the built-in handlers use.
How Authentication Works
The authentication system has three phases:
- Authenticate — examine the request and establish the user's identity (a
ClaimsPrincipal). - Challenge — respond when an unauthenticated user accesses a protected resource.
- Forbid — respond when an authenticated user lacks the required authorisation.
A custom handler implements these by extending AuthenticationHandler<TOptions>.
API Key Authentication
Here's a complete API key handler:
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
{
public const string DefaultScheme = "ApiKey";
public string HeaderName { get; set; } = "X-Api-Key";
}
public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
private readonly IApiKeyValidator _validator;
public ApiKeyAuthenticationHandler(
IOptionsMonitor<ApiKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IApiKeyValidator validator)
: base(options, logger, encoder)
{
_validator = validator;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(Options.HeaderName, out var apiKeyHeader))
{
return AuthenticateResult.NoResult();
}
var apiKey = apiKeyHeader.ToString();
var keyInfo = await _validator.ValidateAsync(apiKey);
if (keyInfo is null)
{
return AuthenticateResult.Fail("Invalid API key.");
}
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, keyInfo.ClientId),
new Claim(ClaimTypes.Name, keyInfo.ClientName),
new Claim("scope", keyInfo.Scope)
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.StatusCode = StatusCodes.Status401Unauthorized;
Response.Headers.Append("WWW-Authenticate", $"ApiKey header=\"{Options.HeaderName}\"");
return Task.CompletedTask;
}
}
The key decisions in HandleAuthenticateAsync:
- Return
NoResult()when the request doesn't contain credentials for this scheme — another handler might authenticate it. - Return
Fail()when credentials are present but invalid. - Return
Success()with a ticket when credentials are valid.
Registration
builder.Services.AddAuthentication(ApiKeyAuthenticationOptions.DefaultScheme)
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationOptions.DefaultScheme, options =>
{
options.HeaderName = "X-Api-Key";
});
builder.Services.AddScoped<IApiKeyValidator, DatabaseApiKeyValidator>();
The API key validator might look like this:
public interface IApiKeyValidator
{
Task<ApiKeyInfo?> ValidateAsync(string apiKey);
}
public class DatabaseApiKeyValidator : IApiKeyValidator
{
private readonly AppDbContext _dbContext;
public DatabaseApiKeyValidator(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<ApiKeyInfo?> ValidateAsync(string apiKey)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(apiKey));
var hashString = Convert.ToHexString(hash);
return await _dbContext.ApiKeys
.Where(k => k.KeyHash == hashString && k.IsActive && k.ExpiresAt > DateTime.UtcNow)
.Select(k => new ApiKeyInfo(k.ClientId, k.ClientName, k.Scope))
.FirstOrDefaultAsync();
}
}
Always store hashed API keys, never plaintext.
HMAC Authentication
For service-to-service communication, HMAC authentication verifies both identity and request integrity:
public class HmacAuthenticationHandler : AuthenticationHandler<HmacAuthenticationOptions>
{
public HmacAuthenticationHandler(
IOptionsMonitor<HmacAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("Authorization", out var authHeader))
{
return AuthenticateResult.NoResult();
}
var headerValue = authHeader.ToString();
if (!headerValue.StartsWith("HMAC ", StringComparison.OrdinalIgnoreCase))
{
return AuthenticateResult.NoResult();
}
var parts = headerValue["HMAC ".Length..].Split(':');
if (parts.Length != 3)
{
return AuthenticateResult.Fail("Invalid HMAC header format.");
}
var clientId = parts[0];
var timestamp = parts[1];
var signature = parts[2];
// Verify timestamp is within acceptable window
if (!long.TryParse(timestamp, out var ticks))
{
return AuthenticateResult.Fail("Invalid timestamp.");
}
var requestTime = new DateTime(ticks, DateTimeKind.Utc);
if (Math.Abs((DateTime.UtcNow - requestTime).TotalMinutes) > 5)
{
return AuthenticateResult.Fail("Request timestamp is too old.");
}
// Look up client's secret key
var secret = Options.GetSecretForClient(clientId);
if (secret is null)
{
return AuthenticateResult.Fail("Unknown client.");
}
// Compute expected signature
var content = $"{Request.Method}{Request.Path}{timestamp}";
var expectedSignature = ComputeHmac(secret, content);
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(signature),
Encoding.UTF8.GetBytes(expectedSignature)))
{
return AuthenticateResult.Fail("Invalid signature.");
}
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, clientId) };
var identity = new ClaimsIdentity(claims, Scheme.Name);
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name);
return AuthenticateResult.Success(ticket);
}
private static string ComputeHmac(string secret, string content)
{
var keyBytes = Encoding.UTF8.GetBytes(secret);
var contentBytes = Encoding.UTF8.GetBytes(content);
var hash = HMACSHA256.HashData(keyBytes, contentBytes);
return Convert.ToBase64String(hash);
}
}
Multiple Authentication Schemes
You can register multiple schemes and choose per endpoint:
builder.Services.AddAuthentication()
.AddJwtBearer("Bearer", options => { /* JWT config */ })
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>("ApiKey", options => { });
// Use JWT for user-facing endpoints
app.MapGet("/api/profile", GetProfile).RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = "Bearer" });
// Use API key for service-to-service
app.MapGet("/api/internal/data", GetData).RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = "ApiKey" });
Key Points
- Extend
AuthenticationHandler<TOptions>to create custom schemes. - Return
NoResult()when the request doesn't contain your scheme's credentials — this allows other schemes to try. - Always use constant-time comparison (
CryptographicOperations.FixedTimeEquals) for signature verification. - Store API keys as hashes, not plaintext.
- Use multiple authentication schemes when different endpoints need different credential types.
Custom authentication handlers fit cleanly into ASP.NET Core's security pipeline. They participate in the same authentication, challenge, and forbid flow as built-in handlers, giving you consistent security semantics regardless of the credential type.