JWT authentication for Web API

June 28th 2024 OAuth ASP.NET Core

Although there's great support for JWT bearer authentication in ASP.NET Core, I had a hard time finding clear instructions on how to add it to an existing ASP.NET Core (Web API) project:

  • The authentication documentation provides a lot of detailed information, but it's still difficult to extract exactly what needs to be done to make it work for JWT.
  • An old blog post focuses more on the JWT, but depends on additional posts about identity providers to get to a working example.
  • The Auth0 tutorial for ASP.NET Core seems to be the most succinct example, but of course expects you to use Auth0 as the identity provider.

In the end I decided to create my own sample project as the basis for discussing the ins and outs of JWT authentication. The goal was to get it working with as little changes to the code as possible and without depending on an external identity provider.

So, instead of getting the JWT from an identity provider, I create it with the Jwt.Net NuGet package in my tests before adding it to the Authorization header of the requests:

var token = JwtBuilder
    .Create()
    .WithAlgorithm(new RS256Algorithm(CreateCertificate()))
    .AddClaim(ClaimName.Issuer, "https://localhost:5000")
    .AddClaim(ClaimName.Audience, "web-api-oauth-test")
    .AddClaim(ClaimName.Subject, "test")
    .AddClaim(
        ClaimName.ExpirationTime,
        DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds()
    )
    .Encode();

using var httpClient = factory.CreateClient();
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer",
    token
);

In case you didn't know, the test visualizer in Visual Studio now has the ability to decode JWTs which can be really convenient to check the contents of the token you're dealing with without any external tools.

JWT decoder in Visual Studio text visualizer

In the Web API project, I added the Microsoft.AspNetCore.Authentication.JwtBearer NuGet package to get access to the AddJwtBearer method I then used to configure the JWT authentication:

builder
    .Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Audience = "web-api-oauth-test";
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidIssuer = "https://localhost:5000",
            SignatureValidator = (token, parameters) => new JsonWebToken(token)
        };
    });

When using an external identity provider, you'd usually set only the Authority (identifies the issuer of the token) and Audience properties (identifies the client the token is intended for). While the Audience is just a string, the Authority contains the URL of the issuer which can be used to obtain the public key to validate the token signature. And it's also used to validate the issuer claim in the token.

Since I'm creating the token myself and there's no URL with the public key, I had to omit the Authority property and set two other properties instead:

  • The ValidIssuer specifies the expected value of the issuer claim.
  • The SignatureValidator is a delegate that replaces the default implementation of signature validation and skips the validation altogether.

Note: It would be more secure to specify the IssuerSigningKey property to be used for validation instead, i.e. the one I use to sign the token when I generate it in my test. However, security wasn't really a concern in this sample. And in production I'd use an identity provider anyway and simply set the Authority URL to get the key from there.

The only thing left is to add the Authorize attribute to controllers or their action methods to mark which endpoints require the request to have a valid Authorization header:

[ApiController]
[Route("[controller]")]
[Authorize]
public class WeatherForecastController : ControllerBase
{
    // ...
}

Those without the attribute will still be accessible anonymously, even if the JWT authentication is configured.

To make troubleshooting of JWT validation easier, it's a good idea to set the logging for Microsoft.AspNetCore.Authentication at least to Information level.

"Logging": {
  "LogLevel": {
    "Microsoft.AspNetCore.Authentication": "Information"
  }
}

This will add details about failed validation to the logs:

info: Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler[1]
      Failed to validate the token.
      Microsoft.IdentityModel.Tokens.SecurityTokenExpiredException: IDX10223: Lifetime validation failed. The token is expired. ValidTo (UTC): '27. 06. 2024 11:46:15', Current time (UTC): '27. 06. 2024 12:46:15'.
         at Microsoft.IdentityModel.Tokens.Validators.ValidateLifetime(Nullable`1 notBefore, Nullable`1 expires, SecurityToken securityToken, TokenValidationParameters validationParameters)
         at Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.ValidateTokenPayloadAsync(JsonWebToken jsonWebToken, TokenValidationParameters validationParameters, BaseConfiguration configuration)
         at Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.ValidateJWSAsync(JsonWebToken jsonWebToken, TokenValidationParameters validationParameters, BaseConfiguration configuration)
info: Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler[7]
      Bearer was not authenticated. Failure message: IDX10223: Lifetime validation failed. The token is expired. ValidTo (UTC): '27. 06. 2024 11:46:15', Current time (UTC): '27. 06. 2024 12:46:15'.

Even for Web API integration tests, you can see these logs in the Output window in VS if you select the Tests category:

Web API integration test logs

You can find full source code for a working sample project in my GitHub repository. The included integration tests make sure that only requests with a valid token succeed. If there's no token included, or if the token is invalid or has expired, the requested will be rejected.

Although you should always use tokens from an identity provider in production scenarios, I find it useful to have sample code that can work without one. It can serve as a tool to learn about JWT authentication. And it can even be useful to test production code without having a dependency on an external identity provider.

Get notified when a new blog post is published (usually every Friday):

If you're looking for online one-on-one mentorship on a related topic, you can find me on Codementor.
If you need a team of experienced software engineers to help you with a project, contact us at Razum.
Copyright
Creative Commons License