ASP.NET Core  

Securing ASP.NET Core APIs with API Keys

As .NET developers, we have all faced this situation: you have built a sleek, high-performing ASP.NET Core Web API, and now it is time to expose it to external services or partners. However, you only want authorized consumers to access your API.

One of the simplest ways to secure a Web API is API Key Authentication.

In this article, I will walk you through five practical patterns for implementing API Key authentication in ASP.NET Core, along with code examples and their pros and cons.

Prerequisites

Before continuing, you should have:

  • Basic understanding of .NET 6 or later

  • Familiarity with ASP.NET Core Web API

  • Knowledge of Middleware and Dependency Injection

  • Understanding of the Authorize attribute

  • Basic knowledge of AuthenticationHandler and HttpContext

1. API Key Authentication using Custom Middleware

Steps:

Let's create a middleware that checks for an X-API-Key header and compares it to a key stored in appsettings.json

// appsettings.json 
{ "ApiKey": "your-super-secret-api-key-12345" }
public class APIKeyMiddleware
 {
     private readonly RequestDelegate _next;
     private const string API_KEY_HEADER_NAME = "X-API-Key";

     public APIKeyMiddleware(RequestDelegate next)
     {
         _next = next;
     }

     public async Task InvokeAsync(HttpContext context, IConfiguration configuration)
     {
         // Skip validation for Swagger/development endpoints
         if (context.Request.Path.StartsWithSegments("/swagger"))
         {
             await _next(context);
             return;
         }

         if (!context.Request.Headers.TryGetValue(API_KEY_HEADER_NAME, out var extractedApiKey))
         {
             context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
             await context.Response.WriteAsync("API Key is missing.");
             return;
         }

         var apiKey = configuration["ApiKey"];
         if (!string.Equals(apiKey, extractedApiKey))
         {
             context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
             await context.Response.WriteAsync("Unauthorized: Invalid API Key.");
             return;
         }

         // API Key is valid — continue processing
         await _next(context);
     }
 }

Now, Register it in Program.cs

app.UseMiddleware<APIKeyMiddleware>();

Run the application and test the endpoint using below powershell script.

Invoke-WebRequest -Uri "https://localhost:7055/weatherforecast" 
                  -Method Get 
                  -Headers @{ "X-API-Key" = "your-super-secret-api-key-12345" }

See the output in PowerShell

Pros

  • Super simple

  • No extra dependencies

  • Fast to implement

Cons

  • No support for multiple keys

  • Bypasses ASP.NET Core’s authentication pipeline

2. API Key Authentication using Custom Authentication Handler

We use into ASP.NET Core’s authentication pipeline using a custom AuthenticationHandler. This gives us full access to claims, policies, and the Authorize attribute.

Steps:

  1. Define ApiKeyOptions

public class ApiKeyOptions : AuthenticationSchemeOptions
{
    public const string DefaultScheme = "ApiKey";
    public string Scheme => DefaultScheme;
    public string AuthenticationType = DefaultScheme;

    // Custom property for the header name
    public string HeaderName { get; set; } = "X-API-Key";
}
  1. Create the Handler

public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyOptions>
{
    private const string API_KEY_HEADER_NAME = "X-API-Key";
    private const string API_KEY_CONFIG_KEY = "ApiKey"; // Key in appsettings.json

    private readonly IConfiguration _configuration;

    public ApiKeyAuthenticationHandler(
        IOptionsMonitor<ApiKeyOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        ISystemClock clock,
        IConfiguration configuration)
        : base(options, logger, encoder, clock)
    {
        _configuration = configuration;
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        // Skip authentication for OPTIONS (preflight)
        if (Context.Request.Method == "OPTIONS")
        {
            return Task.FromResult(AuthenticateResult.NoResult());
        }

        if (!Context.Request.Headers.TryGetValue(API_KEY_HEADER_NAME, out var apiKeyHeader))
        {
            return Task.FromResult(AuthenticateResult.Fail("API Key missing"));
        }

        var apiKey = _configuration[API_KEY_CONFIG_KEY];
        var requestApiKey = apiKeyHeader;

        // Prevent timing attacks with constant-time comparison
        if (!SecureCompare(apiKey, requestApiKey))
        {
            return Task.FromResult(AuthenticateResult.Fail("Invalid API Key"));
        }

        var identity = new ClaimsIdentity(new[] { new Claim("ApiKey", "valid-key") }, Options.Scheme);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Options.Scheme);

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }

    private static bool SecureCompare(string? a, string? b)
    {
        if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b))
            return false;

        if (a.Length != b.Length)
            return false;

        var result = 0;
        for (int i = 0; i < a.Length; i++)
        {
            result |= a[i] ^ b[i];
        }
        return result == 0;
    }
    public class ApiKeyAuthorizationRequirement : IAuthorizationRequirement
    {
        // This is an empty marker for custom policy evaluation
    }
}
  1. Register in Program.cs

builder.Services.AddAuthentication(ApiKeyOptions.DefaultScheme)
    .AddScheme<ApiKeyOptions, ApiKeyAuthenticationHandler>(ApiKeyOptions.DefaultScheme, options =>
    {
        // You can configure options here if needed
    });

// Optional: Add Authorization with Policy (if using IAuthorizationHandler)
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ApiKeyPolicy", policy =>
    {
        policy.RequireAuthenticatedUser();
        policy.Requirements.Add(new ApiKeyAuthorizationRequirement());
    });
});

// Learn more about configuring Swagger/OpenAPI
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
    // Add API Key to Swagger
    options.AddSecurityDefinition("ApiKey", new OpenApiSecurityScheme
    {
        In = ParameterLocation.Header,
        Name = "X-API-Key",
        Type = SecuritySchemeType.ApiKey,
        Description = "Enter your API key"
    });

    options.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "ApiKey"
                }
            },
            Array.Empty<string>()
        }
    });
});
  1. Add API Key in appsettings.json

"ApiKey": "your-super-secret-api-key-12345"
  1. Run the application and test the API.

Invoke-WebRequest -Uri "https://localhost:7055/weatherforecast" 
                   -Method Get 
                 -Headers @{ "X-API-Key" = "your-super-secret-api-key-12345" }
  1. See the output in PowerShell

Pros

  • Full integration with ASP.NET Core auth

  • Supports policies and claims

  • Reusable and testable

Cons

  • Slightly more complex

  • Still uses config-based keys (not scalable)

3. Using DelegatingHandler for Outbound API Calls

So far, we’ve focused on incoming requests. But what about when your service calls another API?

Say you’re hitting https://api.weatherpartner.com, and they require an API key. Instead of adding headers everywhere, use a DelegatingHandler.

Steps:

  1. Create the ApiKeyDelegatingHandler

public class ApiKeyDelegatingHandler : DelegatingHandler
{
    private readonly string _apiKey;
    private readonly string _headerName;

    public ApiKeyDelegatingHandler(string apiKey, string headerName = "X-API-Key")
    {
        _apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey));
        _headerName = headerName;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        // Add the API key to the headers
        request.Headers.Add(_headerName, _apiKey);

        // Log (optional)
        // Console.WriteLine($"Adding API Key to request: {request.RequestUri}");

        // Send the request onward
        return await base.SendAsync(request, cancellationToken);
}
  1. Register HttpClient with the Handler in Program.cs

builder.Services.AddHttpClient<IWeatherService, WeatherService>(client =>
{
    client.BaseAddress = new Uri("https://api.weatherpartner.com/");
})
.AddHttpMessageHandler(sp =>
{
    var configuration = sp.GetRequiredService<IConfiguration>();
    var apiKey = configuration["ExternalWeatherApi:ApiKey"]
        ?? throw new InvalidOperationException("API Key not configured.");

    return new ApiKeyDelegatingHandler(apiKey, "X-API-Key");
});
  1. Add API-Key in appsettings.json

"ExternalWeatherApi": {
    "ApiKey": "your-actual-api-key-here-12345"
  }
  1. Use that function from any services like this.

public async Task<string> GetWeatherDataAsync()
    {
        var response = await httpClient.GetAsync("/data");
        response.EnsureSuccessStatusCode();
        return await httpClient.GetStringAsync("/data");
    }

4. Database-Backed API Keys

If you need multiple keys, Key Expiration, Usage tracking, Then it’s better to store keys to the database.

Steps:

  1. Define ApiKey Model

public class ApiKey
{
    public int Id { get; set; }
    public string Key { get; set; } = string.Empty;
    public string Name { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
    public bool IsActive { get; set; } = true;
}
  1. Add AppDbContext

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
    public DbSet<ApiKey> ApiKeys { get; set; }
}
  1. Seed sample key to check with

modelBuilder.Entity<ApiKey>().HasData(new ApiKey
{
    Id = 1,
    Key = "your-secure-api-key-here-12345", // Generate this securely
    Name = "InternalService",
    CreatedAt = DateTime.UtcNow,
    IsActive = true
});
  1. Add API Key Authentication Handler class

public class ApiKeyAuthHandler : AuthenticationHandler<ApiKeyAuthOptions>
{
    private const string API_KEY_HEADER_NAME = "X-API-Key";

    private readonly AppDbContext _dbContext; // For DB lookup
    private readonly IOptionsMonitor<ApiKeySettings> _config; // For config lookup

    public ApiKeyAuthHandler(
        IOptionsMonitor<ApiKeyAuthOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        ISystemClock clock,
        AppDbContext dbContext,
        IOptionsMonitor<ApiKeySettings> config)
        : base(options, logger, encoder, clock)
    {
        _dbContext = dbContext;
        _config = config;
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue(API_KEY_HEADER_NAME, out StringValues apiKeyHeader))
        {
            return AuthenticateResult.Fail("API Key missing");
        }

        var apiKey = apiKeyHeader.FirstOrDefault();

        if (string.IsNullOrEmpty(apiKey))
        {
            return AuthenticateResult.Fail("API Key missing");
        }

        bool isValid = await ValidateKeyFromDatabase(apiKey) || ValidateKeyFromConfig(apiKey);

        if (!isValid)
        {
            return AuthenticateResult.Fail("Invalid API Key");
        }

        var claims = new[] { new Claim(ClaimTypes.Name, "API Client") };
        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);

        return AuthenticateResult.Success(ticket);
    }

    private async Task<bool> ValidateKeyFromDatabase(string key)
    {
        var apiEntity = await _dbContext.ApiKeys
            .FirstOrDefaultAsync(x => x.Key == key && x.IsActive);
        return apiEntity != null;
    }

    private bool ValidateKeyFromConfig(string key)
    {
        return _config.CurrentValue.ApiKeys.Any(k => k.Key == key);
    }
}

// Options class
public class ApiKeyAuthOptions : AuthenticationSchemeOptions { }
  1. Register Authentication in program.cs

builder.Services.AddAuthentication("ApiKey")
.AddScheme<ApiKeyAuthOptions, ApiKeyAuthHandler>("ApiKey", null);
  1. Secure the controller

[Authorize(AuthenticationSchemes = "ApiKey")]
  1. Test the API.

Pros

  • Full lifecycle management

  • Support for revocation, rotation, and expiration

  • Ready for multi-tenant systems

Cons

  • Database hit on every request (consider caching..)

5. Using Third Party Library

Steps:

Install Nuget Package

dotnet add package Microsoft.AspNetCore.Authentication.ApiKey

Add ApiKey class

public class ApiKey : IApiKey
{
    // Required property: Key
    public string Key { get; set; } = string.Empty;

    // Required property: Name
    public string Name { get; set; } = string.Empty;

    // Required property: OwnerName
    public string OwnerName { get; set; } = string.Empty;

    // Required property: Claims
    public IReadOnlyCollection<Claim> Claims { get; set; } = Array.Empty<Claim>();

    // Optional property: Roles
    public IReadOnlyCollection<string> Roles { get; set; } = Array.Empty<string>();
}
public class DatabaseApiKeyProvider : IApiKeyProvider
{
    // Simulated list - replace with DB call in production
    private readonly List<ApiKey> _apiKeys = new()
    {
    new ApiKey { Key = "your-secure-api-key-here-12345", Name = "LocalAPIService" },
    new ApiKey { Key = "your-secure-api-key-here-123456", Name = "GlobalAPIService" }
    };

    /// <summary>
    /// Validates if the provided API key exists and is valid.
    /// </summary>
    public async Task<IApiKey?> IsValidAsync(string key)
    {
        var apiKey = _apiKeys.FirstOrDefault(k => k.Key == key);
        return apiKey;
    }

    /// <summary>
    /// Provides additional details about the API key (e.g., metadata).
    /// </summary>
    public async Task<IApiKey?> ProvideAsync(string key)
    {
        return await IsValidAsync(key); // Reuse logic
    }
}

Add authentication in Program.cs

builder.Services.AddAuthentication(ApiKeyDefaults.AuthenticationScheme)
    .AddApiKeyInHeader<DatabaseApiKeyProvider>(
        options =>
        {
            options.Realm = "SUN Microsystems Nepal";
            options.KeyName = "X-API-Key"; // Header name
        });

builder.Services.AddAuthorization();

Add multiple keys in appsettings.json

"ApiKeySettings": {
  "RequireHttps": false,
  "AllowedKeys": [
    {
      "Key": "your-secure-api-key-here-12345",
      "Name": "LocalAPIService"
    },
    {
      "Key": "your-secure-api-key-here-123456",
      "Name": "GlobalAPIService"
    }
  ]
}

Now, protect the API Controller

[Authorize(AuthenticationSchemes = ApiKeyDefaults.AuthenticationScheme)]

Run the application, test the endpoint via PowerShell

Invoke-RestMethod -Uri "https://localhost:7055/weatherforecast" -Method Get -Headers @{"X-API-Key" = "your-secure-api-key-here-12345"} -ContentType "application/json"

This will return 200 for correct API Key

Security Consideration while protecting an API with API Keys

  • Never expose keys in URLs (avoid query strings unless absolutely necessary)

  • Always use HTTPS

  • Hash and salt stored keys (never store plain text)

  • Implement rate limiting and logging per key.

  • Rotate keys regularly

  • Avoid using API keys for user-level authentication (user OAuth2/JWT instead)