Adding API Key Authentication to ASP.NET Core Web API with AuthenticationHandler

Adding API Key Authentication to ASP.NET Core Web API with AuthenticationHandler

Authentication is the process by which a user of a system is identified. ASP.NET Core allows multiple methods of authentication through authentication handlers which each support a different method of authentication. This can be with a cookie, a JSON Web Token (JWT), through OpenID or others.

What is an AuthenticationHandler?

An AuthenticationHandler is a class that is responsible for authenticating a request. For each method of authentication, for example you can see how the JwtBearerHandler in ASP.NET Core 7 validates a provided JWT to identify the user for that request. 

You can also see how if a JWT is not provided, it is also responsible for sending the appropriate challenge to the client by modifying the response. For more detail on what authentication handlers are responsible for and how they operate I’d recommend reading the Overview of ASP.NET Core Authentication page on Microsoft Learn. I also have an additional blog post covering the ASP.NET Core authentication process in more detail. 

Implementing API Key Authentication 

API key authentication is where a user is identified through a credential in the form of a secret key. The server is sent the secret key with each request, which it will then check to ensure that the client is authorised to access the requested resource or perform the specified operation. 

There are many different options to implementing API key authentication, such as checking the key in a piece of custom ASP.NET Core middleware or through a controller attribute. In this post, I am going to implement it by extending the ASP.NET Core authentication middleware. This is my preference as it will allow any existing policies to operate the same as they would with any other method of authentication. 

To implement API key authentication, we will need to do the following: 

  • Add an options class to load settings such as the header name to check for the API key. 
  • Add an ApiKeyHandler by extending AuthenticationHandler
  • Register the options and handler with the authentication middleware. 

You can find the steps for each of these below.  

In the example below, I will use an API key from appsettings.json as the valid key, however, in a real scenario this will probably be checked against a database. 

Creating ApiKeyAuthenticationOptions 

We will need to store some settings in appsettings.json to allow the API key authentication process to be modified. In this example, the name of the header to check for the API key, the authorised API key, and whether the key is allowed for read-only or read and write access (to demonstrate changing claims). 

To do this we will need to create an ApiKeySchemeOptions class which inherits AuthenticationSchemeOptions

public class ApiKeySchemeOptions : AuthenticationSchemeOptions 
{ 
    public string HeaderName { get; set; } = "X-Api-Key"; 
    public string? ApiKey { get; set; } 
    public bool ReadOnly { get; set; } = true; 

    public override void Validate() 
    { 
        if (string.IsNullOrEmpty(HeaderName)) 
        { 
            throw new ArgumentException("Header name must be provided."); 
        } 

        if (string.IsNullOrEmpty(ApiKey)) 
        { 
            throw new ArgumentException("API key must be provided."); 
        } 
    } 
} 

This class also implements the `Validate` method to ensure that all values are provided when necessary. The class also sets a default header name of X-Api-Key if one is not provided.

To finish this, we will need to add the following section to the appsettings.json file.

"ApiKeyOptions": {
  "HeaderName": "X-Api-Key",
  "ApiKey": "my-test-api-key",
  "ReadOnly": true
}

The next step will be to implement the ApiKeyHandler class to handle the authentication process.

Implementing the ApiKeyHandler

The ApiKeyHandler will be responsible for checking that a provided API key is valid, and returning an AuthenticateResult to indicate if the request is authenticated or not.

Create the ApiKeyHandler class which inherits AuthenticationHandler<ApiKeySchemeOptons>. This will provide access to the API key authentication options we setup in the previous step. As we need to access the HttpRequest we will need to use IHttpContextAccessor. To do this we will need to add IHttpContextAccessor to the class constructor and store it in a private field.

After this has been done, your class should look like the following.

public class ApiKeyHandler : AuthenticationHandler<ApiKeySchemeOptions>
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public TestAuth(IOptionsMonitor<ApiKeySchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IHttpContextAccessor httpContextAccessor) : base(options, logger, encoder, clock)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        throw new NotImplementedException();
    }
}

The next step, will be to implement the HandleAuthenticateAsync method, which will need to carry out the following steps:

  1. Check the API key header is present, if not, it should return not return a result.
  2. Ensure that only one API key has been provided, if more than one is provided authentication should fail.
  3. Check that the provided API key is correct, if not, authentication should fail.
  4. Now that the API key has been validated, create a ticket for the user with an appropriate list of claims.
  5. Return a successful authentication with the user information.

You can see this process followed in the implementation below.

public class ApiKeyHandler : AuthenticationHandler<ApiKeySchemeOptions>
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public ApiKeyHandler(IOptionsMonitor<ApiKeySchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IHttpContextAccessor httpContextAccessor) : base(options, logger, encoder, clock)
    {
        _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        // Retrieve the API key from the specified header name.
        StringValues apiKeys = StringValues.Empty;

        bool apiKeyPresent = _httpContextAccessor.HttpContext?.Request.Headers.TryGetValue(Options.HeaderName, out apiKeys) ?? false;

        // Return 'NoResult' if the header is not present as this is not intended for the ApiKeyHandler to handle.
        if (!apiKeyPresent)
        {
            return AuthenticateResult.NoResult();
        }

        // Ensure only one API key is provided, otherwise return 'Fail' with an error message.
        if (apiKeys.Count > 1)
        {
            return AuthenticateResult.Fail("Multiple API keys found in request. Please only provide one key.");
        }

        // Ensure the API key provided is valid.
        if (string.IsNullOrEmpty(Options.ApiKey) || !Options.ApiKey.Equals(apiKeys.FirstOrDefault()))
        {
            return AuthenticateResult.Fail("Invalid API key.");
        }

        // Create a ClaimsIdentity with all the claims associated with the API key. This would usually come from a database.
        List<Claim> claims = new()
        {
            new Claim(ClaimTypes.NameIdentifier, Options.ApiKey),
            new Claim(ClaimTypes.Name, "API Key User")
        };

        if (Options.ReadOnly)
        {
            claims.Add(new Claim(ClaimTypes.Role, "ReadOnly")); ;
        }
        else
        {
            claims.Add(new Claim(ClaimTypes.Role, "ReadWrite")); ;
        }

        // Create a ClaimsIdentity, ClaimsPrincipal and return an AuthenticationTicket for the user with the claims.
        ClaimsIdentity identity = new(claims, Scheme.Name);

        ClaimsPrincipal principal = new(identity);

        AuthenticationTicket ticket = new(principal, Scheme.Name);

        return AuthenticateResult.Success(ticket);
    }
}

This also includes a check for the ReadOnly option to determine if the added role should be ReadOnly or ReadWrite.

Finally, the claims are used to create a ClaimsIdentity, which is then passed to a ClaimsPrincipal to represent the user. This is then wrapped in a AuthenticationTicket and returned as a successful authentication using AuthenticateResult.Success.

Registering with the Authentication Middleware

Now that the ApiKeyHandler has been implemented, we need to register the services we need, our options, and our new scheme with the ASP.NET Core middleware.

To do this we can add the following code to our application startup.cs file.

builder.Services.AddHttpContextAccessor();
builder.Services.AddAuthentication().AddScheme<ApiKeySchemeOptions, ApiKeyHandler>("ApiKey", (options) =>
{
    builder.Configuration.GetRequiredSection("ApiKeyOptions").Bind(options);
});
builder.Services.AddAuthorization();

This will add the HttpContextAccessor service used in the ApiKeyHandler, and register the ApiKey authentication scheme, while binding the required options from the ApiKey appsettings.json section.

Bonus: Updating Swagger for API Key Authentication

To make it easier to test the API while using API key authentication, we can make some changes to Swagger to add the necessary options to the UI and tell Swagger how to modify the requests when using that authentication method.

We will need to replace builder.Services.AddSwaggerGen(); with the following code to add the necessary option in startup.cs.

builder.Services.AddSwaggerGen(options =>
{
    options.AddSecurityDefinition("APIKey", new OpenApiSecurityScheme
    {
        In = ParameterLocation.Header,
        Description = "Please enter your API Key",
        Name = "X-Api-Key",
        Type = SecuritySchemeType.ApiKey
    });

    options.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "APIKey"
                }
            },
            Array.Empty<string>()
        }
    });
});

This will automatically enable authentication in Swagger and add an API key authentication option which allows the valid API key to be provided to authenticate a request.

If you would like to find out more about how to modify Swagger for different authentication methods, I’d recommend reading the Add Security Definitions and Requirements section in the Swashbuckle.AspNetCore project readme file.

Summary 

We have added API key authentication to an ASP.NET Core project by implementing the appropriate authentication handler and registering options to allow the behaviour of the handler to be controlled.

The Swagger configuration has also been updated to define a SecurityDefinition and a SecurityRequirement, which will allow the request to be authenticated using an API key while exploring the API in Swagger.