REST API Engineering · Lesson 12 of 19

API Key Authentication in ASP.NET Core

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