Securing ASP.NET Core WebApi with an API Key
I read the article from Aram Tchekrekjian, which he goes in great length about techniques to secure a Web API, that is, using a Middleware and using an attribute that uses the IAsyncActionFilter
. I would like to add another technique to this list using also an attribute, but one that uses the IAsyncAuthorizationFilter
instead. This filter is called earlier in the chain of filters and can stop early a bad request using an invalid API Key. To learn more about filters, check out the documentation.
I will use the starter ASP.NET Core 3 API template that comes with dotnet. You can create it through Visual Studio or using the command line dotnet new webapi <ProjectName>.
In my scenario, I will use a combination of Client Id/Api Key.
Setup
At the root of the project, create 2 folders that will host the files:
- Attributes
- Filters
In the Attributes folder, create a class file named ApiKeyAuthorizeAttribute. In the Filters folder, create a class file named ApiKeyAuthorizeAsyncFilter. Your solution should look like this
Open the ApiKeyAuthorizeAttribute class file. Decorate the class with the following AttributeUsage
1 |
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] |
This will determine how the attribute class can be used. The attribute can be applied on a class, in this case the controller, or on a method, in this case the method that can handle a route.
The class should also derive from the TypeFilterAttribute
. Your class should look like this:
1 2 3 4 5 6 7 |
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class ApiKeyAuthorizeAttribute : TypeFilterAttribute { public ApiKeyAuthorizeAttribute() : base(typeof(ApiKeyAuthorizeAsyncFilter)) { } } |
Now for the ApiKeyAuthorizeAsyncFilter class, it should implement the IAsyncAuthorizationFilter
interface.
This interface gives us access to a method Task OnAuthorizationAsync(AuthorizationFilterContext context)
and it’s in this method that we will verify the API Key associated to a client. Since this is a filter, it will be registered in the DI, and thus give us access to all the DI goodies.
To validate the api key, I created an ApiKeyService class that handles the logic to verify if I’m authorized or not. My implementation class implement an interface that can have the following shape:
1 2 3 4 |
public interface IApiKeyService { Task<bool> IsAuthorized(string clientId, string apiKey); } |
Now I can implement the logic in my filter. Since I have access to the context, I have access to the request, and so I can extract the information I need to validate. My implementation looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
using System; using System.Collections.Generic; using System.Security.Claims; using System.Threading.Tasks; using ApiKeyAuthDemo.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Logging; namespace ApiKeyAuthDemo.Filters { public class ApiKeyAuthorizeAsyncFilter : IAsyncAuthorizationFilter { public static string ApiKeyHeaderName = "ApiKey"; public static string ClientIdHeaderName = "ClientId"; private readonly ILogger<ApiKeyAuthorizeAsyncFilter> _logger; private readonly IApiKeyService _apiKeyService; public ApiKeyAuthorizeAsyncFilter(ILogger<ApiKeyAuthorizeAsyncFilter> logger, IApiKeyService apiKeyService) { _logger = logger; _apiKeyService = apiKeyService; } public async Task OnAuthorizationAsync(AuthorizationFilterContext context) { var request = context.HttpContext.Request; var hasApiKeyHeader = request.Headers.TryGetValue(ApiKeyHeaderName, out var apiKeyValue); if (hasApiKeyHeader) { _logger.LogDebug("Found the header {ApiKeyHeader}. Starting API Key validation", ApiKeyHeaderName); if (apiKeyValue.Count != 0 && !string.IsNullOrWhiteSpace(apiKeyValue)) { if (request.Headers.TryGetValue(ClientIdHeaderName, out var clientIdValue) && clientIdValue.Count != 0 && !string.IsNullOrWhiteSpace(clientIdValue)) { if (await _apiKeyService.IsAuthorized(apiKeyValue, clientIdValue)) { _logger.LogDebug("Client {ClientId} successfully logged in with key {ApiKey}", clientIdValue, apiKeyValue); var apiKeyClaim = new Claim("apikey", apiKeyValue); var subject = new Claim(ClaimTypes.Name, clientIdValue); var principal = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim> { apiKeyClaim, subject }, "ApiKey")); context.HttpContext.User = principal; return; } _logger.LogWarning("ClientId {ClientId} with ApiKey {ApiKey} is not authorized", clientIdValue, apiKeyValue); } else { _logger.LogWarning("{HeaderName} header not found or it was null or empty",ClientIdHeaderName); } } else { _logger.LogWarning("{HeaderName} header found, but api key was null or empty", ApiKeyHeaderName); } } else { _logger.LogWarning("No ApiKey header found."); } context.Result = new UnauthorizedResult(); } } } |
Don’t forget to register your IApiKeyService
implementation in your Startup.ConfigureServices
method:
1 |
services.AddScoped<IApiKeyService,ApiKeyService>(); |
Once I have implemented my filter, it’s time to add it to my controller. In the controller class WeatherForecastController
, decorate it with
[ApiKeyAuthorize].
1 2 3 4 5 6 |
[ApiController] [Route("[controller]")] [ApiKeyAuthorize] public class WeatherForecastController : ControllerBase { // code here ... } |
Test
Using postman, I execute a GET request to https://localhost:5001/WeatherForecast. I don’t put any Headers for my Client Id or my Api Key. I get a 401.
If I put the ApiKey header, but not the ClientId header, it will also trigger a 401 unauthorized because the ClientId header is missing
If the combination of the api key and the client id is wrong, it will also tell me I’m not authorized
Now if I put the right combination, I will get access and a 200
Things not to forget
You should think about a caching mechanism for the api key/client id combination as you don’t want to hit your underlying data store to validate those for every request. It will become expensive.