.NET & C# Development · Lesson 32 of 92
API Key Auth in 15 Minutes — Simple, Effective, Secure
When API Keys Are Appropriate
Use API keys for:
- Machine-to-machine: a third-party service calling your API on a schedule
- Webhooks: your server validating inbound webhook calls from Stripe, GitHub, etc.
- SDKs and integrations: when the caller is a server, not a browser/mobile app
- Simple public APIs: where full OAuth is too heavy and users just need a key
Don't use API keys as a replacement for user authentication in browser-facing apps — XSS can steal them from localStorage. JWTs with short expiry and HttpOnly cookies are better there.
The Data Model
C#
public class ApiKey
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty; // e.g., "CI Pipeline Key"
public string KeyHash { get; set; } = string.Empty; // SHA-256 hash of the raw key
public string KeyPrefix { get; set; } = string.Empty; // first 8 chars for lookup hint
public int OwnerId { get; set; }
public User Owner { get; set; } = null!;
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; }
public DateTime? ExpiresAt { get; set; }
public DateTime? LastUsedAt { get; set; }
public string[] Scopes { get; set; } = Array.Empty<string>(); // e.g., ["read", "write"]
}C#
public class ApiKeyConfiguration : IEntityTypeConfiguration<ApiKey>
{
public void Configure(EntityTypeBuilder<ApiKey> builder)
{
builder.HasIndex(k => k.KeyHash).IsUnique();
builder.HasIndex(k => k.KeyPrefix);
builder.Property(k => k.Scopes).HasConversion(
v => string.Join(',', v),
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries));
}
}Generating and Storing Keys
C#
public class ApiKeyService
{
private readonly AppDbContext _db;
public ApiKeyService(AppDbContext db) => _db = db;
public (string rawKey, ApiKey entity) CreateKey(string name, int ownerId, string[] scopes)
{
// Format: prefix_randomBase64 e.g., "sfai_a3Bc9xQz..."
var randomPart = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.Replace('+', '-').Replace('/', '_').TrimEnd('=');
var rawKey = $"sfai_{randomPart}";
var prefix = rawKey[..8]; // first 8 chars for lookup
var hash = HashKey(rawKey);
var entity = new ApiKey
{
Name = name,
KeyHash = hash,
KeyPrefix = prefix,
OwnerId = ownerId,
Scopes = scopes,
CreatedAt = DateTime.UtcNow
};
return (rawKey, entity);
}
public string HashKey(string rawKey)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawKey));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
public async Task<ApiKey?> FindByRawKeyAsync(string rawKey, CancellationToken ct = default)
{
var hash = HashKey(rawKey);
return await _db.ApiKeys
.Include(k => k.Owner)
.FirstOrDefaultAsync(k => k.KeyHash == hash && k.IsActive, ct);
}
}Expose a key management endpoint to admins:
C#
[HttpPost("api-keys")]
[Authorize]
public async Task<IActionResult> CreateApiKey([FromBody] CreateApiKeyRequest request)
{
var ownerId = User.GetUserId();
var (rawKey, entity) = _apiKeyService.CreateKey(request.Name, ownerId, request.Scopes);
_db.ApiKeys.Add(entity);
await _db.SaveChangesAsync();
// Return the raw key ONCE — it cannot be retrieved again
return Ok(new { key = rawKey, id = entity.Id, message = "Store this key — it won't be shown again." });
}Custom Authentication Handler
C#
public class ApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private const string ApiKeyHeaderName = "X-Api-Key";
private const string ApiKeyQueryParam = "api_key";
private readonly ApiKeyService _apiKeyService;
private readonly AppDbContext _db;
public ApiKeyAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ApiKeyService apiKeyService,
AppDbContext db)
: base(options, logger, encoder)
{
_apiKeyService = apiKeyService;
_db = db;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Read key from header first, then query string fallback
string? rawKey = Request.Headers.TryGetValue(ApiKeyHeaderName, out var headerValues)
? headerValues.FirstOrDefault()
: Request.Query.TryGetValue(ApiKeyQueryParam, out var queryValues)
? queryValues.FirstOrDefault()
: null;
if (string.IsNullOrEmpty(rawKey))
return AuthenticateResult.NoResult(); // let other handlers try
var apiKey = await _apiKeyService.FindByRawKeyAsync(rawKey, Context.RequestAborted);
if (apiKey is null)
return AuthenticateResult.Fail("Invalid API key.");
if (apiKey.ExpiresAt.HasValue && apiKey.ExpiresAt < DateTime.UtcNow)
return AuthenticateResult.Fail("API key has expired.");
// Update last used timestamp (fire-and-forget, don't await)
apiKey.LastUsedAt = DateTime.UtcNow;
_ = _db.SaveChangesAsync();
// Build claims principal from the API key
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, apiKey.OwnerId.ToString()),
new(ClaimTypes.Name, apiKey.Name),
new("api_key_id", apiKey.Id.ToString()),
new("auth_method", "api_key"),
};
foreach (var scope in apiKey.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);
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.StatusCode = 401;
Response.ContentType = "application/json";
return Response.WriteAsync("{\"error\":\"API key required. Provide X-Api-Key header.\"}");
}
}Registering the Handler
C#
public const string ApiKeyScheme = "ApiKey";
builder.Services.AddScoped<ApiKeyService>();
builder.Services.AddAuthentication(options =>
{
// Default scheme is JWT for browser clients
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
// JWT config...
})
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(ApiKeyScheme, _ => { });Combining API Key with JWT — Mixed Auth Policy
Create a policy that accepts either authentication method:
C#
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ApiKeyOrJwt", policy =>
policy.AddAuthenticationSchemes(
JwtBearerDefaults.AuthenticationScheme,
ApiKeyScheme)
.RequireAuthenticatedUser());
options.AddPolicy("WriteScope", policy =>
policy.AddAuthenticationSchemes(ApiKeyScheme)
.RequireClaim("scope", "write"));
});Apply to endpoints:
C#
[HttpPost("data")]
[Authorize(Policy = "ApiKeyOrJwt")] // either method works
public async Task<IActionResult> PostData([FromBody] DataRequest request) { /* ... */ }
[HttpPost("webhook")]
[Authorize(AuthenticationSchemes = ApiKeyScheme, Policy = "WriteScope")]
public async Task<IActionResult> HandleWebhook([FromBody] WebhookPayload payload) { /* ... */ }Rate Limiting Per API Key
Use ASP.NET Core's built-in rate limiting (introduced in .NET 7):
C#
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("per_api_key", httpContext =>
{
// Use the API key ID as the partition key
var keyId = httpContext.User.FindFirstValue("api_key_id");
return keyId is not null
? RateLimitPartition.GetSlidingWindowLimiter(keyId, _ =>
new SlidingWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
})
: RateLimitPartition.GetNoLimiter("anonymous");
});
options.RejectionStatusCode = 429;
options.OnRejected = async (context, _) =>
{
context.HttpContext.Response.Headers["Retry-After"] = "60";
await context.HttpContext.Response.WriteAsJsonAsync(new
{
error = "Rate limit exceeded. Maximum 100 requests per minute."
});
};
});
app.UseRateLimiter();Apply to routes:
C#
app.MapControllers().RequireRateLimiting("per_api_key");
// Or per-endpoint:
[EnableRateLimiting("per_api_key")]
[HttpGet("data")]
public IActionResult GetData() { /* ... */ }Key Management Best Practices
C#
// Revoke a key
[HttpDelete("api-keys/{id}")]
[Authorize]
public async Task<IActionResult> RevokeApiKey(int id)
{
var key = await _db.ApiKeys
.FirstOrDefaultAsync(k => k.Id == id && k.OwnerId == User.GetUserId());
if (key is null) return NotFound();
key.IsActive = false;
await _db.SaveChangesAsync();
return NoContent();
}Key guidelines:
- Always hash keys with SHA-256 before storing — never store raw keys
- Use a prefix (
sfai_) so users can identify which service a key belongs to - Set expiry dates on keys, especially for CI/CD integrations
- Log every authenticated request with the key ID (not the key itself)
- Rotate keys on suspected compromise — revoke old, issue new
- Show the raw key exactly once at creation; inform the user to store it securely