DEV Community

Fabrizio Bagalà
Fabrizio Bagalà

Posted on • Updated on

JWT Authentication in ASP.NET

ℹ️ Information
The code in the following article was tested with ASP.NET 6 and 7.

When it comes to security in web applications, one of the main challenges is ensuring user authentication and authorization. Among the various technologies available to manage authentication are JWTs.

What is a JWT?

JWT stands for JSON Web Token and it's an open standard for creating access tokens that allow the secure sharing of information between different parties in a compact, self-contained format.

A JWT consists of three parts:

  1. Header: It contains metadata that describes the token itself. It usually includes the type of token, which is JWT, and the signing algorithm used, like HMAC SHA256 or RSA.
  2. Payload: It contains the so-called "claims", which are assertions about the subject (the user, in most cases) and additional information. There are three types of claims: registered, public, and private. Registered claims are a predefined set of claims with specific meanings, such as iss (token issuer), exp (token expiration date), sub (token subject), among others. Public and private claims are user-defined.
  3. Signature: It is used to verify that the sender of the JWT is whom they claim to be and to ensure that the message has not been altered along the way.

Once created, the JWT is encoded and signed, making it a secure token to send between the client and server.

JWTs are commonly used to implement token-based authentication in RESTful APIs. When a user successfully authenticates, the server creates a JWT and sends it to the client. The client can then use the JWT to authenticate subsequent requests. Since the JWT contains information about the user's permissions, the server can use the JWT to authorize the operations requested by the client.

Pros and cons of using JWT

One of the main advantages of using JWTs is their stateless nature. They contain all the necessary information for authentication, eliminating the need to maintain a server-side session. This saves resources and promotes application scalability. Additionally, JWTs are very compact, making them suitable for sending via URL, POST parameters, or in the HTTP header. This compactness is particularly useful when data needs to be sent across the network. From a security perspective, JWTs can be digitally signed, allowing for the verification of data integrity. If necessary, a JWT can be encrypted to ensure data confidentiality. Finally, a JWT is self-contained as it holds all the necessary information about who the user is and what permissions they have, significantly simplifying authorization decisions.

However, there are also drawbacks to using JWTs. Although JWTs are compact, they tend to be larger than alternatives such as session cookies. If the JWT's payload is too large, it could affect the performance of the application. Implementing JWTs can also be more complex than other solutions, such as session-based authentication. Furthermore, the security of JWTs is tied to the robustness of the encryption algorithm and key used. Since JWTs are stateless, invalidating or updating a JWT without contacting the server can be challenging, which can pose problems in scenarios like user logout or password change. Finally, from a security perspective, if a JWT is stolen, it can be used to impersonate the user and, due to the difficulty of invalidating a JWT, often the solution involves setting short expiration times and frequently renewing tokens.

Therefore, when deciding to use JWTs, it is important to carefully consider these advantages and disadvantages to determine if it suits the specific use case.

Example of use of JWT

Here is an example of a minimal API in ASP.NET that uses the JWT to authenticate a user.

var builder = WebApplication.CreateBuilder(args);

var jwtOptionsSection = builder.Configuration.GetRequiredSection("Jwt");
builder.Services.Configure<JwtOptions>(jwtOptionsSection);

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(jwtOptions =>
{
    var configKey = jwtOptionsSection["Key"];
    var key = Encoding.UTF8.GetBytes(configKey);

    jwtOptions.TokenValidationParameters = new TokenValidationParameters
    {
        ValidIssuer = jwtOptionsSection["Issuer"],
        ValidAudience = jwtOptionsSection["Audience"],
        IssuerSigningKey = new SymmetricSecurityKey(key),
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = false,
        ValidateIssuerSigningKey = true
    };
});

builder.Services.AddAuthorization();

builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.ReportApiVersions = true;
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ApiVersionReader = new UrlSegmentApiVersionReader();
});

builder.Services.AddTransient<IUserService, UserService>();
builder.Services.AddTransient<IJwtService, JwtService>();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

var versionSet = app.NewApiVersionSet()
    .HasApiVersion(new ApiVersion(1.0))
    .ReportApiVersions()
    .Build();

app.MapPost("v{version:apiVersion}/token", [AllowAnonymous]([FromBody] UserInfo user,
    [FromServices] IUserService userService, [FromServices] IJwtService jwtService) =>
{
    var storedUser = userService.GetUser(user?.Username);
    if (!userService.IsAuthenticated(user?.Password, storedUser?.PasswordHash))
    {
        return Results.Unauthorized();
    }

    var tokenString = jwtService.GenerateToken(storedUser);
    return Results.Ok(new { token = tokenString });
}).WithApiVersionSet(versionSet);

app.MapGet("v{version:apiVersion}/weather", [Authorize]() =>
{
    var data = Enumerable.Range(1, 5)
        .Select(i => new WeatherForecast
        {
            Date = DateTime.Now.AddDays(i),
            TemperatureC = Random.Shared.Next(-20, 55)
        })
        .ToArray();

    return Results.Ok(data);
}).WithApiVersionSet(versionSet);

app.Run();
Enter fullscreen mode Exit fullscreen mode

Let's analyze this example step by step.

Step 1: Creating the application

var builder = WebApplication.CreateBuilder(args);
Enter fullscreen mode Exit fullscreen mode

We start by creating an instance of the application builder. This builder will be used to configure services and middleware before the application starts.

Step 2: Configuring JWT options

We use the options pattern to read the Jwt section from the configuration file and map it onto an instance of the JwtOptions class.

var jwtOptionsSection = builder.Configuration.GetRequiredSection("Jwt");
builder.Services.Configure<JwtOptions>(jwtOptionsSection);
Enter fullscreen mode Exit fullscreen mode

GetRequiredSection("Jwt") retrieves the Jwt section from the configuration file. Configure<JwtOptions>(jwtOptionsSection) maps the settings of the Jwt section onto the JwtOptions object, making it available for injection into other classes through the dependency injection system.

The used appsettings.json configuration file is:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Jwt": {
    "Issuer": "example.com",
    "Audience": "example.com",
    "Key": "This is a secure secret key"
  }
}
Enter fullscreen mode Exit fullscreen mode

In this file, the Jwt section contains the settings related to the JWT, such as Issuer, Audience, and Key.

The JwtOptions class is a POCO (Plain Old CLR Object) class that corresponds to the settings of the Jwt section:

public class JwtOptions
{
    public string Issuer { get; init; } = string.Empty;
    public string Audience { get; init; } = string.Empty;
    public string Key { get; init; } = string.Empty;
}
Enter fullscreen mode Exit fullscreen mode

Each property in this class is initialized with the corresponding value from the Jwt section of the configuration file. This class will then be injected into any class that requires these settings.

⚠️ Warning
In a production environment, do not store sensitive data in the code or appsettings.json. Rather use Vault Secrets.

Step 3: Adding authentication

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(jwtOptions =>
{
    var configKey = jwtOptionsSection["Key"];
    var key = Encoding.UTF8.GetBytes(configKey);

    jwtOptions.TokenValidationParameters = new TokenValidationParameters
    {
        ValidIssuer = jwtOptionsSection["Issuer"],
        ValidAudience = jwtOptionsSection["Audience"],
        IssuerSigningKey = new SymmetricSecurityKey(key),
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = false,
        ValidateIssuerSigningKey = true
    };
});
Enter fullscreen mode Exit fullscreen mode

The authentication service is added and configured to use JWT authentication as the default authentication scheme. The token validation options are set from jwtOptionsSection, which was configured in the previous step.

Note that the NuGet package Microsoft.AspNetCore.Authentication.JwtBearer needs to be installed.

Step 4: Adding authorization

builder.Services.AddAuthorization();
Enter fullscreen mode Exit fullscreen mode

The authorization service is added, which allows access to controllers and actions to be restricted based on user roles or policies.

Step 5: Adding API versioning

builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.ReportApiVersions = true;
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ApiVersionReader = new UrlSegmentApiVersionReader();
});
Enter fullscreen mode Exit fullscreen mode

To be able to use API versioning, you need to install the NuGet package Asp.Versioning.Http.

In the code, we are setting the default version of the API to version 1.0, assuming this default version when not specified, and reading the API version from the URL segment of the request. We are also choosing to report the API versions in the HTTP responses, which can be useful for your API customers to know about the different versions available.

🔎 Insight
Even though it's just an example, I decided to add versioning because I consider it an important aspect when writing an API. Let's look at some key reasons:

  • Seamless evolution: APIs change and evolve over time. You might need to add new features or change existing ones. Versioning allows you to make these changes without disrupting applications using previous versions of your API.
  • Controlled deprecation: You might want to deprecate some features or entire versions of your API in the future. Versioning allows you to do this in a controlled manner, providing users with a notice and a transition period before removing the deprecated features or versions.
  • Flexibility for users: Versioning gives your users the flexibility to choose which version of your API to use. For instance, they may choose to stick to a previous version if the changes introduced in a new version are not compatible with their applications.
  • Documentation and testing: Each version of your API can have its own documentation and test suite, which can help both you and your users to understand and verify the functionality of each version.

Step 6: Services registration

builder.Services.AddTransient<IJwtService, JwtService>();
builder.Services.AddTransient<IUserService, UserService>();
Enter fullscreen mode Exit fullscreen mode

In this step, IJwtService and IUserService are registered as transient services, which means a new instance will be created each time a component requests one of these services. They are responsible for managing the JWT and managing users, respectively.

Let's start with IJwtService. This interface defines a GenerateToken method, which takes a UserDto object as an argument and returns a string. This string represents the JWT token generated for the user.

public record UserDto(Guid Id, string Username, string PasswordHash, string Email, string Role);
Enter fullscreen mode Exit fullscreen mode
public interface IJwtService
{
    string GenerateToken(UserDto? user);
}
Enter fullscreen mode Exit fullscreen mode

The JwtService class implements IJwtService. It has a variable _options of type JwtOptions. This variable is initialized via the class constructor, which receives an IOptions<JwtOptions> object. IOptions is a wrapper containing configuration options (in this case JwtOptions) from a configuration file (for instance, appsettings.json).

The GenerateToken method of JwtService creates a JWT token for the user passed as an argument. First of all, it ensures the user is not null, otherwise, it throws an ArgumentNullException. Then, it creates a symmetric security key using the key present in the JWT options. Subsequently, it creates a security token descriptor. This descriptor contains a number of important information, such as the user's claims, the token's expiration date, the issuer, the audience, and the signing credentials. Finally, it uses JwtSecurityTokenHandler to create and write the token.

public sealed class JwtService : IJwtService
{
    private readonly JwtOptions _options;

    public JwtService(IOptions<JwtOptions> options)
    {
        _options = options.Value ?? throw new ArgumentNullException(nameof(options));
    }

    public string GenerateToken(UserDto? user)
    {
        ArgumentNullException.ThrowIfNull(user);

        var key = Encoding.ASCII.GetBytes(_options.Key);
        var securityKey = new SymmetricSecurityKey(key);
        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(new[]
            {
                new Claim(ClaimTypes.Name, user.Username),
                new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
                new Claim(ClaimTypes.Email, user.Email),
                new Claim(ClaimTypes.Role, user.Role)
            }),
            Expires = DateTime.UtcNow.AddMinutes(5),
            Issuer = _options.Issuer,
            Audience = _options.Audience,
            SigningCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature)
        };
        var tokenHandler = new JwtSecurityTokenHandler();
        var token = tokenHandler.CreateToken(tokenDescriptor);
        return tokenHandler.WriteToken(token);
    }
}
Enter fullscreen mode Exit fullscreen mode

The created token can be decoded through JWT.IO.

jwt.io

The IUserService interface defines two methods: GetUser, which returns a UserDto object based on the provided username, and IsAuthenticated, which verifies if a password matches a provided password hash.

public interface IUserService
{
    UserDto? GetUser(string? username);
    bool IsAuthenticated(string? password, string? passwordHash);
}
Enter fullscreen mode Exit fullscreen mode

The UserService class implements IUserService. It has a variable _users, which is a list of UserDto objects. In the constructor of this class, this list is initialized with two example users, whose data are hardcoded.

The GetUser method returns a UserDto object that has the provided username, or null if such a user does not exist. The IsAuthenticated method checks if the provided password, once hashed, matches the provided password hash. It uses the Verify method of the NuGet package BCrypt.Net-Next to perform this check.

public sealed class UserService : IUserService
{
    private readonly List<UserDto> _users;

    public UserService()
    {
        _users = new List<UserDto>
        {
            new(Guid.NewGuid(), "user1", BCrypt.Net.BCrypt.HashPassword("password1"), "user1@gmail.com", "Admin"),
            new(Guid.NewGuid(), "user2", BCrypt.Net.BCrypt.HashPassword("password2"), "user2@gmail.com", "User")
        };
    }

    public UserDto? GetUser(string? username)
    {
        ArgumentNullException.ThrowIfNull(username);

        return _users.SingleOrDefault(u => u.Username == username);
    }

    public bool IsAuthenticated(string? password, string? passwordHash)
    {
        ArgumentNullException.ThrowIfNull(password);
        ArgumentNullException.ThrowIfNull(passwordHash);

        return BCrypt.Net.BCrypt.Verify(password, passwordHash);
    }
}

Enter fullscreen mode Exit fullscreen mode

Step 7: Building the application

var app = builder.Build();
Enter fullscreen mode Exit fullscreen mode

This code builds the web application with all the configurations and services that have been registered.

Step 8: Middleware configuration

app.UseAuthentication();
app.UseAuthorization();
Enter fullscreen mode Exit fullscreen mode

The authentication and authorization middleware are added to the application.

Step 9: Creating the API version set

var versionSet = app.NewApiVersionSet()
    .HasApiVersion(new ApiVersion(1.0))
    .ReportApiVersions()
    .Build();
Enter fullscreen mode Exit fullscreen mode

An API version set is created to be used with the endpoints.

Creating a version set is useful for grouping and managing API versions in a consistent way. The NewApiVersionSet() method creates a new version set. A specific API version can be added to this version set using the HasApiVersion() method.

Additionally, the ReportApiVersions() method enables API version reporting. This setting allows including information about supported versions in HTTP responses. This can be very helpful for developers using your API, as it allows them to quickly see what API versions are available.

Finally, the Build() method builds the version set. This version set is then used in combination with API endpoints to manage different API versions.

Step 10: Defining the endpoints

Endpoints represent access points to our API. In our example, we are defining two main endpoints: one for token generation and one for accessing protected data.

  • Token Endpoint (POST /token): This endpoint is responsible for generating the JWT token. It receives a UserInfo object in the request body, which contains user credentials (username and password). These details are then passed to the user service for authentication. If the user is correctly authenticated, the JWT service generates a token that is returned to the client. This endpoint is decorated with the AllowAnonymous attribute, meaning that no token needs to be provided to access it.
public record UserInfo(string? Username, string? Password);
Enter fullscreen mode Exit fullscreen mode
app.MapPost("v{version:apiVersion}/token", [AllowAnonymous]([FromBody] UserInfo user,
    [FromServices] IUserService userService, [FromServices] IJwtService jwtService) =>
{
    var storedUser = userService.GetUser(user?.Username);
    if (!userService.IsAuthenticated(user?.Password, storedUser?.PasswordHash))
    {
        return Results.Unauthorized();
    }

    var tokenString = jwtService.GenerateToken(storedUser);
    return Results.Ok(new { token = tokenString });
}).WithApiVersionSet(versionSet);
Enter fullscreen mode Exit fullscreen mode
  • Protected Endpoint (GET /weather): This endpoint is intended to provide protected data, in this case, a series of weather forecasts. The Authorize attribute ensures that only authenticated users with a valid token can access this endpoint. If a client tries to access this endpoint without providing a valid token, they will receive an HTTP 401 Unauthorized error.
app.MapGet("v{version:apiVersion}/weather", [Authorize]() =>
{
    var data = Enumerable.Range(1, 5)
        .Select(i => new WeatherForecast
        {
            Date = DateTime.Now.AddDays(i),
            TemperatureC = Random.Shared.Next(-20, 55)
        })
        .ToArray();

    return Results.Ok(data);
}).WithApiVersionSet(versionSet);
Enter fullscreen mode Exit fullscreen mode

The endpoint routes use a version:apiVersion parameter that allows clients to specify the API version they want to use. This is part of our versioning strategy, as discussed in step 5.

Step 11: Running the application

app.Run();
Enter fullscreen mode Exit fullscreen mode

The application is started and begins listening for incoming requests.

Conclusion

In this article, I addressed the nuances of implementing JSON Web Token (JWT) in ASP.NET for managing user authentication. I outlined the essentials of JWT and why it has become a popular choice in modern Web development. From configuring properties to generating and decoding tokens, I explored each step in the process. I also examined the advantages and potential disadvantages of its use.

In summary, the goal of this guide was to provide a foundation for integrating JWT authentication into ASP.NET applications. Although I have tried to cover the main aspects, I always remember that understanding the basic principles and adhering to security best practices are critical when using new technology.

References

Top comments (0)