Implementing JWT Authentication and Refresh Tokens in ASP.NET Core .NET 10

dev.to

Let's Understand How It Works

Before we start implementing authentication, let's first understand the basic flow.

When a user logs in, they send their credentials (such as email and password) to the server through a login API. The server validates those credentials and, if they are correct, generates a JWT access token and returns it to the client.

The JWT contains claims that provide information about the authenticated user, such as their user ID, name, email, or role.

Whenever the client wants to access a protected API, it sends the JWT in the Authorization header:

Authorization: Bearer <access-token>
Enter fullscreen mode Exit fullscreen mode

The server then validates the token before allowing the request to access the protected resource.

This gives us the basic authentication flow:

User → Login API → Validate Credentials → Generate JWT → Client → Protected API

Basic authentication flow using JWT

Refresh Token

Access tokens should generally be short-lived. A short lifetime reduces the amount of time an attacker can use a stolen access token.

However, this introduces another problem. If the access token expires after a short period, the user would have to log in again to obtain a new one. Obviously, asking users to enter their credentials repeatedly would not provide a good user experience.

This is where refresh tokens come into the picture.

During login, after validating the user's credentials, the server generates two tokens:

  • Access Token — short-lived and used to access protected APIs.
  • Refresh Token — long-lived and used to obtain a new access token.

The client stores both tokens. When the access token expires, instead of asking the user to log in again, the client sends the refresh token to the refresh endpoint.

The server validates the refresh token and, if it is still valid, generates a new access token and a new refresh token.

The process looks like this:

Login → Access Token + Refresh Token

Then, when the access token expires:

Refresh Token → Validate → New Access Token + New Refresh Token

Authentication flow using refresh tokens

This allows us to keep access tokens short-lived while still providing a smooth experience for the user.

Tools and Technologies Used

For this project, I'll be using the following technologies:

  • Visual Studio / VS Code — Development environment
  • .NET 10 — Backend framework
  • ASP.NET Core Web API — Building the REST API
  • SQL Server — Database
  • Entity Framework Core — ORM for database operations
  • ASP.NET Core Identity — User and role management
  • Swagger — Testing the APIs

Connection String

We'll use SQL Server as the database for this project.

Add the SQL Server connection string to appsettings.json:

"ConnectionStrings":{"DefaultConnection":"Server=localhost;Database=JwtAuthDemo;Trusted_Connection=True;TrustServerCertificate=True;"}
Enter fullscreen mode Exit fullscreen mode

We'll use this connection string later when configuring Entity Framework Core and registering our DbContext.

Models

Let's start by creating the model that represents our application user.

Create a new folder named Models. Inside the Models folder, create a class named ApplicationUser.

We'll inherit from IdentityUser<Guid> instead of the default IdentityUser. This allows ASP.NET Core Identity to use a Guid as the primary key for our users.

public class ApplicationUser : IdentityUser<Guid>
{
    public string Name { get; set; } = string.Empty;

    public string RefreshToken { get; set; } = string.Empty;

    public DateTime RefreshTokenExpiryTime { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Here, ApplicationUser extends the built-in IdentityUser class and adds the properties that are specific to our application.

  • Name — stores the user's name.
  • RefreshToken — stores the currently issued refresh token.
  • RefreshTokenExpiryTime — stores the expiration time of the refresh token.

ASP.NET Core Identity already provides several properties such as Id, Email, UserName, PasswordHash, and other user-management fields. By extending IdentityUser<Guid>, we can use all of that functionality while adding our own application-specific properties.

ApplicationDbContext

Next, we need to create our Entity Framework Core DbContext.

Create a new class named AppDbContext :

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid>, Guid>
{
    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options)
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

Our AppDbContext inherits from:

IdentityDbContext<ApplicationUser, IdentityRole<Guid>, Guid>
Enter fullscreen mode Exit fullscreen mode

The three generic parameters specify:

  • ApplicationUser — our custom user entity.
  • IdentityRole<Guid> — the Identity role entity, using Guid as its primary key.
  • Guid — the type of the primary key used by Identity.

By inheriting from IdentityDbContext, Entity Framework Core will create and manage the required ASP.NET Core Identity tables, such as users, roles, claims, and user-role relationships.

We can also add our own DbSet properties to this context later if the application contains additional entities.

Registering AppDbContext and Identity

Now, we need to register our AppDbContext and ASP.NET Core Identity in Program.cs.

First, retrieve the connection string from appsettings.json and register the AppDbContext with Entity Framework Core:

string connectionString = builder.Configuration
    .GetConnectionString("DefaultConnection")!;

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString));
Enter fullscreen mode Exit fullscreen mode

Since we are using ASP.NET Core Identity, we also need to register Identity and tell it to use our AppDbContext for storing Identity data:

builder.Services.AddIdentity<ApplicationUser, IdentityRole<Guid>>()
    .AddEntityFrameworkStores<AppDbContext>()
    .AddDefaultTokenProviders();
Enter fullscreen mode Exit fullscreen mode

Here:

  • ApplicationUser is our custom Identity user.
  • IdentityRole<Guid> represents roles and uses Guid as the primary key.
  • AddEntityFrameworkStores<AppDbContext>() tells Identity to store users, roles, claims, and other Identity-related data using Entity Framework Core.
  • AddDefaultTokenProviders() registers the default token providers provided by ASP.NET Core Identity.

Finally, we need to add the authentication and authorization middleware to the request pipeline.

Make sure they are added before MapControllers():

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

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

UseAuthentication() is responsible for identifying the user from the provided authentication credentials, such as a JWT.

UseAuthorization() then determines whether the authenticated user has permission to access the requested resource.

The order is important:

Request
   ↓
UseAuthentication()
   ↓
UseAuthorization()
   ↓
MapControllers()
   ↓
Controller
Enter fullscreen mode Exit fullscreen mode

We'll configure JWT authentication shortly. Once that configuration is in place, UseAuthentication() will be responsible for validating the JWT sent with requests to protected endpoints.

JWT Configuration

Now that we have configured Entity Framework Core and ASP.NET Core Identity, the next step is to configure JWT authentication.

We need a few JWT-related settings such as the issuer, audience, and secret key.

Add the following configuration to appsettings.json:

"JWT":{"ValidAudience":"https://localhost:7001","ValidIssuer":"https://localhost:7001"}
Enter fullscreen mode Exit fullscreen mode

Replace https://localhost:7001 with the URL of your API if your application is running on a different port.

ValidAudience

ValidAudience specifies who the JWT is intended for.

This value is used to validate the aud claim in the JWT. When the server receives a token, it can check whether the token was issued for the expected audience.

For example:

{"aud":"https://localhost:7001"}
Enter fullscreen mode Exit fullscreen mode

If the audience in the token doesn't match the configured audience, the token validation will fail.

ValidIssuer

ValidIssuer identifies the application or authentication server that issued the JWT.

This value is used to validate the iss claim in the token.

For example:

{"iss":"https://localhost:7001"}
Enter fullscreen mode Exit fullscreen mode

When validating a token, the application checks whether the issuer matches the configured value.

JWT Secret Key

The JWT also needs a secret key to sign and validate tokens.

For local development, instead of putting the secret directly inside appsettings.json, we'll store it using .NET User Secrets.

First, initialize User Secrets for the project:

dotnet user-secrets init
Enter fullscreen mode Exit fullscreen mode

Then store the JWT secret:

dotnet user-secrets set "JWT:Secret" "your-32-characters-long-super-strong-jwt-secret-key"
Enter fullscreen mode Exit fullscreen mode

The secret should be long, random, and kept private. Never commit your JWT secret to source control.

For production environments, use a proper secret-management solution such as a cloud key vault or another secure secrets manager.

Configuring JWT Authentication

Now let's configure JWT Bearer authentication in Program.cs.

Add the following configuration:

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme =
        JwtBearerDefaults.AuthenticationScheme;

    options.DefaultChallengeScheme =
        JwtBearerDefaults.AuthenticationScheme;

    options.DefaultScheme =
        JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    options.SaveToken = true;
    options.RequireHttpsMetadata = false;

    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,

        ValidAudience = builder.Configuration["JWT:ValidAudience"],
        ValidIssuer = builder.Configuration["JWT:ValidIssuer"],

        ClockSkew = TimeSpan.Zero,

        IssuerSigningKey = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(
                builder.Configuration["JWT:Secret"]!
            )
        )
    };
});
Enter fullscreen mode Exit fullscreen mode

Let's understand some of the important settings.

ValidateIssuer

ValidateIssuer = true
Enter fullscreen mode Exit fullscreen mode

This tells ASP.NET Core to validate the iss claim of the JWT against the configured ValidIssuer.

ValidateAudience

ValidateAudience = true
Enter fullscreen mode Exit fullscreen mode

This validates the aud claim against the configured ValidAudience.

ClockSkew

ClockSkew = TimeSpan.Zero
Enter fullscreen mode Exit fullscreen mode

ClockSkew allows some tolerance when validating token expiration times. Setting it to TimeSpan.Zero means no additional time is added as a tolerance.

IssuerSigningKey

IssuerSigningKey = new SymmetricSecurityKey(
    Encoding.UTF8.GetBytes(
        builder.Configuration["JWT:Secret"]!
    )
)
Enter fullscreen mode Exit fullscreen mode

The secret key is converted into a byte array and used to create the symmetric signing key.

This key is important because the same secret is used to validate that the JWT was signed by our application and has not been modified.

At this point, our application knows how to validate JWTs. In the next sections, we'll create the service responsible for generating the access and refresh tokens.

Creating the Database with EF Core Migrations

Now that our AppDbContext, Identity, and JWT configuration are in place, we can create the database using Entity Framework Core migrations.

Open Package Manager Console in Visual Studio:

Tools → NuGet Package Manager → Package Manager Console

Run the following command to create the initial migration:

Add-MigrationInitialCreate
Enter fullscreen mode Exit fullscreen mode

This creates a migration containing the changes required to create our database schema, including the tables required by ASP.NET Core Identity.

Next, apply the migration to the SQL Server database:

Update-Database
Enter fullscreen mode Exit fullscreen mode

After running these commands successfully, Entity Framework Core will create the database and the required Identity tables in SQL Server.

You can now open SQL Server Management Studio (SSMS) or SQL Server Object Explorer and verify that the database and Identity tables have been created.

Role Constants

Instead of using role names such as "Admin" and "User" throughout the application, we'll keep them in one place.

Create a folder named Constants. Inside it, create a class named Roles.

// Domain/Constants/Roles.cs

namespace Practice_Backend.Domain.Constants;

public class Roles
{
    public const string Admin = "Admin";
    public const string User = "User";
}
Enter fullscreen mode Exit fullscreen mode

Now, whenever we need to check or assign a role, we can use Roles.Admin or Roles.User instead of hardcoding the role name.

For example:

[Authorize(Roles = Roles.Admin)]
Enter fullscreen mode Exit fullscreen mode

This also makes it easier to change role names later without having to search through the entire application.

Seeding Roles

Our application needs two roles:

  • Admin
  • User

Rather than manually creating these roles in the database, we can seed them when the application starts.

Create a folder named Seeder inside the Infrastructure project and create a class named RoleSeeder.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Practice_Backend.Domain.Constants;

namespace Practice_Backend.Infrastructure.Seeder;

public class RoleSeeder
{
    public static async Task SeedData(IApplicationBuilder app)
    {
        using var scope = app.ApplicationServices.CreateScope();

        var roleManager = scope.ServiceProvider
            .GetRequiredService<RoleManager<IdentityRole<Guid>>>();

        var logger = scope.ServiceProvider
            .GetRequiredService<ILogger<RoleSeeder>>();

        var roles = new[]
        {
            Roles.Admin,
            Roles.User
        };

        foreach (var role in roles)
        {
            if (await roleManager.RoleExistsAsync(role))
            {
                continue;
            }

            var result = await roleManager.CreateAsync(
                new IdentityRole<Guid>(role)
            );

            if (result.Succeeded)
            {
                logger.LogInformation(
                    "Role {Role} created successfully",
                    role
                );
            }
            else
            {
                var errors = result.Errors
                    .Select(e => e.Description);

                logger.LogError(
                    "Failed to create role {Role}. Errors: {Errors}",
                    role,
                    string.Join(", ", errors)
                );
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The important part here is that we first check whether a role already exists:

if (await roleManager.RoleExistsAsync(role))
{
    continue;
}
Enter fullscreen mode Exit fullscreen mode

This prevents the seeder from trying to create the same role every time the application starts.

Seeding the Admin User

Creating the Admin role is not enough. We also need an administrator account that can be assigned this role.

We'll create a separate AdminSeeder for this purpose.

Create another class named AdminSeeder inside the Seeder folder:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Practice_Backend.Domain.Constants;
using Practice_Backend.Domain.Entities;

namespace Practice_Backend.Infrastructure.Seeder;

public class AdminSeeder
{
    public static async Task SeedData(IApplicationBuilder app)
    {
        using var scope = app.ApplicationServices.CreateScope();

        var userManager = scope.ServiceProvider
            .GetRequiredService<UserManager<ApplicationUser>>();

        var logger = scope.ServiceProvider
            .GetRequiredService<ILogger<AdminSeeder>>();

        try
        {
            var admin = await userManager
                .FindByEmailAsync("admin@gmail.com");

            if (admin == null)
            {
                admin = new ApplicationUser
                {
                    Name = "Admin",
                    UserName = "admin@gmail.com",
                    Email = "admin@gmail.com",
                    EmailConfirmed = true,
                    SecurityStamp = Guid.NewGuid().ToString()
                };

                var result = await userManager.CreateAsync(
                    admin,
                    "Admin@123"
                );

                if (!result.Succeeded)
                {
                    var errors = result.Errors
                        .Select(e => e.Description);

                    logger.LogError(
                        "Failed to create admin. Errors: {Errors}",
                        string.Join(", ", errors)
                    );

                    return;
                }

                logger.LogInformation("Admin user created");
            }

            if (!await userManager.IsInRoleAsync(admin, Roles.Admin))
            {
                var result = await userManager.AddToRoleAsync(
                    admin,
                    Roles.Admin
                );

                if (!result.Succeeded)
                {
                    var errors = result.Errors
                        .Select(e => e.Description);

                    logger.LogError(
                        "Failed to assign Admin role. Errors: {Errors}",
                        string.Join(", ", errors)
                    );

                    return;
                }

                logger.LogInformation(
                    "Admin role assigned successfully"
                );
            }
        }
        catch (Exception ex)
        {
            logger.LogCritical(
                ex,
                "Error occurred while seeding admin"
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

There are two important checks in this seeder.

First, we check whether the admin user already exists:

var admin = await userManager
    .FindByEmailAsync("admin@gmail.com");
Enter fullscreen mode Exit fullscreen mode

If the user doesn't exist, we create the account.

After that, we check whether the user already has the Admin role:

if (!await userManager.IsInRoleAsync(admin, Roles.Admin))
Enter fullscreen mode Exit fullscreen mode

If the role hasn't been assigned, we assign it using:

await userManager.AddToRoleAsync(
    admin,
    Roles.Admin
);
Enter fullscreen mode Exit fullscreen mode

This makes the seeder idempotent—we can run it every time the application starts without continuously creating duplicate users or roles.

Note: In a real production application, don't hardcode an administrator password such as Admin@123 in source code. Store it in a secure configuration mechanism such as User Secrets for development and a secret manager in production.

Registration and Login

Now that Identity and JWT authentication are configured, let's implement the actual registration and login flow.

Registration

The Register method is responsible for creating a new user and issuing tokens after successful registration.

First, we check whether an account with the provided email already exists:

var existingUser = await userManager.FindByEmailAsync(command.Email);

if (existingUser != null)
{
    throw new Exception(
        "User with this email already exists"
    );
}
Enter fullscreen mode Exit fullscreen mode

If an account already exists, we throw a Exception instead of creating another account.

We also perform a validation before creating the user:

if (command.Password != command.ConfirmPassword)
{
    throw new Exception(
        "password and confirm password doesn't match"
    );
}


Enter fullscreen mode Exit fullscreen mode

The validation makes sure that the password and confirmation password match.

Once the validation succeeds, we create our ApplicationUser:

ApplicationUser user = new()
{
    Email = command.Email,
    SecurityStamp = Guid.NewGuid().ToString(),
    UserName = command.Email,
    Name= command.FullName,
    EmailConfirmed = true,
};
Enter fullscreen mode Exit fullscreen mode

We then let ASP.NET Core Identity create the user:

var createUserResult = await userManager
    .CreateAsync(user, command.Password);
Enter fullscreen mode Exit fullscreen mode

Identity takes care of processing the password and storing the user securely.

If user creation fails, we collect the Identity errors and throw a Exception:

if (createUserResult.Succeeded == false)
{
    var errors = createUserResult.Errors
        .Select(e => e.Description);

    throw new Exception(
        $"Failed to create user. Errors: {string.Join(", ", errors)}"
    );
}
Enter fullscreen mode Exit fullscreen mode

Assigning the User Role

After creating the user, we assign the User role:

var addUserToRoleResult = await userManager
    .AddToRoleAsync(user, Roles.User);
Enter fullscreen mode Exit fullscreen mode

This is important because the user's role will later be included as a claim in the JWT.

If the role assignment fails, we return an error:

if (addUserToRoleResult.Succeeded == false)
{
    var errors = addUserToRoleResult.Errors
        .Select(e => e.Description);

    throw new BadRequestException(
        $"Failed to assign user role. Errors: {string.Join(", ", errors)}"
    );
}
Enter fullscreen mode Exit fullscreen mode

Creating Claims

Once the user has been created and assigned a role, we create the claims that will be included in the JWT:

List<Claim> authClaims =
[
    new Claim(
        ClaimTypes.NameIdentifier,
        user.Id.ToString()
    ),

    new Claim(
        ClaimTypes.Email,
        user.Email
    ),

    new Claim(
        ClaimTypes.Name,
        user.UserName
    ),

    new Claim(
        JwtRegisteredClaimNames.Jti,
        Guid.NewGuid().ToString()
    )
];
Enter fullscreen mode Exit fullscreen mode

We then retrieve the roles assigned to the user:

var userRoles = await userManager.GetRolesAsync(user);
Enter fullscreen mode Exit fullscreen mode

Each role is added as a ClaimTypes.Role claim:

foreach (var userRole in userRoles)
{
    authClaims.Add(
        new Claim(ClaimTypes.Role, userRole)
    );
}
Enter fullscreen mode Exit fullscreen mode

This allows ASP.NET Core's authorization system to determine the user's role when the JWT is later presented to a protected endpoint.

Generating the Tokens

Now we can generate the access token and refresh token:

var accessToken =
    tokenService.GenerateAccessToken(authClaims);

string refreshToken =
    tokenService.GenerateRefreshToken();
Enter fullscreen mode Exit fullscreen mode

The refresh token and its expiration time are then stored on the user:

user.RefreshToken = refreshToken;
user.RefreshTokenExpiryTime =
    DateTime.UtcNow.AddDays(7);

await userManager.UpdateAsync(user);
Enter fullscreen mode Exit fullscreen mode

  public async Task<RegisterCommandResponse> Register(RegisterCommand command)
  {

      var existingUser = await userManager.FindByEmailAsync(command.Email);
      if (existingUser != null)
      {
          throw new Exception("User with this email already exists");
      }

      if(command.Password != command.ConfirmPassword)
      {
          throw new Exception("password and confirm password doesn't match");
      }





      ApplicationUser user = new()
      {
          Email = command.Email,
          SecurityStamp = Guid.NewGuid().ToString(),
          UserName = command.Email,
          Name= command.Name,
          EmailConfirmed = true,
      };





      var createUserResult = await userManager.CreateAsync(user, command.Password);


      if (createUserResult.Succeeded == false)
      {
          var errors = createUserResult.Errors.Select(e => e.Description);

          throw new BadRequestException ($"Failed to create user. Errors: {string.Join(", ", errors)}");
      }

      var addUserToRoleResult = await userManager.AddToRoleAsync(user: user, role: Roles.User);

      if (addUserToRoleResult.Succeeded == false)
      {
          var errors = addUserToRoleResult.Errors.Select(e => e.Description);
          throw new BadRequestException( $"Failed to assign user role. Errors: {string.Join(", ", errors)}");
      }




       List<Claim> authClaims = [
          new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
          new Claim(ClaimTypes.Email, user.Email),
          new Claim(ClaimTypes.Name, user.UserName),
          new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), 
      ];

      var userRoles = await userManager.GetRolesAsync(user);

      foreach (var userRole in userRoles)
      {
          authClaims.Add(new Claim(ClaimTypes.Role, userRole));
      }

      var accessToken = tokenService.GenerateAccessToken(authClaims);

      string refreshToken = tokenService.GenerateRefreshToken();


      user.RefreshToken = refreshToken;
      user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(7);

      await userManager.UpdateAsync(user);





      return new RegisterCommandResponse
      {
          accessToken = accessToken,
          refreshToken = refreshToken
      };
  }
    ```
{% endraw %}


# Login

The login process is very similar, except that instead of creating a new user, we first validate the existing user's credentials.

We find the user using the email address:
{% raw %}


```csharp
var user = await userManager
    .FindByEmailAsync(command.Email);

if (user == null)
{
    throw new UnAuthorizedException(
        "User with this Email is not registered with us."
    );
}
Enter fullscreen mode Exit fullscreen mode

Next, we validate the password using Identity's UserManager:

bool isValidPassword =
    await userManager.CheckPasswordAsync(
        user,
        command.Password
    );

if (isValidPassword == false)
{
    throw new UnAuthorizedException(
        "invalid credentials"
    );
}
Enter fullscreen mode Exit fullscreen mode

If the credentials are valid, we create the claims in the same way as we did during registration:

List<Claim> authClaims =
[
    new Claim(
        ClaimTypes.NameIdentifier,
        user.Id.ToString()
    ),

    new Claim(
        ClaimTypes.Email,
        user.Email
    ),

    new Claim(
        ClaimTypes.Name,
        user.UserName
    ),

    new Claim(
        JwtRegisteredClaimNames.Jti,
        Guid.NewGuid().ToString()
    )
];
Enter fullscreen mode Exit fullscreen mode

Then we retrieve the user's roles and add them to the claims:

var userRoles =
    await userManager.GetRolesAsync(user);

foreach (var userRole in userRoles)
{
    authClaims.Add(
        new Claim(ClaimTypes.Role, userRole)
    );
}
Enter fullscreen mode Exit fullscreen mode

Finally, we generate a new access token and refresh token:

var accessToken =
    tokenService.GenerateAccessToken(authClaims);

string refreshToken =
    tokenService.GenerateRefreshToken();
Enter fullscreen mode Exit fullscreen mode

The refresh token is stored against the user and given a seven-day expiration:

user.RefreshToken = refreshToken;
user.RefreshTokenExpiryTime =
    DateTime.UtcNow.AddDays(7);

await userManager.UpdateAsync(user);
Enter fullscreen mode Exit fullscreen mode

The refresh token will become especially important when the access token expires. Instead of forcing the user to log in again, we can use the refresh token to obtain a new pair of tokens.

public async Task<LoginCommandResponse> Login(LoginCommand command)
{

    var user = await userManager.FindByEmailAsync(command.Email);
    if (user == null)
    {
        throw new UnAuthorizedException("User with this Email is not registered with us.");
    }
    bool isValidPassword = await userManager.CheckPasswordAsync(user, command.Password);
    if (isValidPassword == false)
    {
         throw new UnAuthorizedException("invalid credentials");
    }

    List<Claim> authClaims = [
        new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
        new Claim(ClaimTypes.Email, user.Email),
        new Claim(ClaimTypes.Name, user.UserName),
        new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), 
    ];

    var userRoles = await userManager.GetRolesAsync(user);

    foreach (var userRole in userRoles)
    {
        authClaims.Add(new Claim(ClaimTypes.Role, userRole));
    }

    var accessToken = tokenService.GenerateAccessToken(authClaims);

    string refreshToken = tokenService.GenerateRefreshToken();


    user.RefreshToken = refreshToken;
    user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(7);

    await userManager.UpdateAsync(user);

    return new LoginCommandResponse
    {
        accessToken = accessToken,
        refreshToken = refreshToken
    };
}

Enter fullscreen mode Exit fullscreen mode

Refresh Token

The access token we generate during login is short-lived. Once it expires, the client can no longer use it to access protected endpoints.

Instead of asking the user to log in again, we can use the refresh token to generate a new access token.

Let's implement this flow in the Refresh method of our AuthService.

Getting the User from the Expired Token

The first step is to extract the user's claims from the expired access token:

var principal =
    tokenService.GetPrincipalFromExpiredToken(
        command.accessToken
    );
Enter fullscreen mode Exit fullscreen mode

Normally, an expired JWT would fail lifetime validation. However, our token service has a separate method that validates the token while allowing us to retrieve the claims from an expired access token.

From the principal, we can retrieve the username:

var username = principal.Identity.Name;
Enter fullscreen mode Exit fullscreen mode

We can then use this username to find the corresponding user:

var user =
    await userManager.FindByNameAsync(username);

if (user == null)
{
    throw new UnAuthorizedException(
        "user is null."
    );
}
Enter fullscreen mode Exit fullscreen mode

Validating the Refresh Token

Finding the user is not enough. We also need to make sure that the refresh token provided by the client is the same refresh token that we have stored for that user.

if (user.RefreshToken != command.refreshToken)
{
    throw new UnAuthorizedException(
        "refresh token doesn't match."
    );
}
Enter fullscreen mode Exit fullscreen mode

We also check whether the refresh token has expired:

if (user.RefreshTokenExpiryTime <= DateTime.UtcNow)
{
    throw new UnAuthorizedException(
        "refresh token time expired."
    );
}
Enter fullscreen mode Exit fullscreen mode

So, a refresh request is accepted only when:

  1. The access token can be validated and its claims extracted.
  2. The corresponding user exists.
  3. The provided refresh token matches the stored refresh token.
  4. The refresh token has not expired.

Generating New Tokens

Once all the validations pass, we generate a new access token using the claims from the existing token:

var newAccessToken =
    tokenService.GenerateAccessToken(
        principal.Claims
    );
Enter fullscreen mode Exit fullscreen mode

We also generate a completely new refresh token:

var newRefreshToken =
    tokenService.GenerateRefreshToken();
Enter fullscreen mode Exit fullscreen mode

We then replace the old refresh token with the new one:

user.RefreshToken = newRefreshToken;

user.RefreshTokenExpiryTime =
    DateTime.UtcNow.AddDays(7);

await userManager.UpdateAsync(user);
Enter fullscreen mode Exit fullscreen mode

This means the old refresh token is no longer valid.

This approach is known as refresh token rotation. Every successful refresh generates a new refresh token instead of continuing to use the same one.

The client can now replace its old tokens with these newly generated tokens and continue making authenticated requests.

  public async Task<RefreshCommandResponse> Refresh(RefreshCommand command)
  {
      var principal = tokenService.GetPrincipalFromExpiredToken(command.accessToken);
      var username = principal.Identity.Name;

      var user = await userManager.FindByNameAsync(username);
      if (user == null)
      {
         throw new UnAuthorizedException("user is null.");
      }

      if (user == null || user.RefreshToken != command.refreshToken)
      {
          throw new UnAuthorizedException("refresh token doesn't match.");
      }

      if (user == null ||  user.RefreshTokenExpiryTime <= DateTime.UtcNow)
      {
          throw new UnAuthorizedException("refresh token time expired.");
      }

      var newAccessToken = tokenService.GenerateAccessToken(principal.Claims);
      var newRefreshToken = tokenService.GenerateRefreshToken();

      user.RefreshToken = newRefreshToken;
      user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(7);

      await userManager.UpdateAsync(user);

      return new RefreshCommandResponse
      {
          accessToken = newAccessToken,
          refreshToken = newRefreshToken
      };
  }

Enter fullscreen mode Exit fullscreen mode

Logout

Now that we have registration, login, and refresh token functionality, we also need to implement logout.

Logging out should invalidate the refresh token so that it can no longer be used to obtain new access tokens.

In our implementation, the logout operation is handled by the Logout method.

Getting the Current User

We first check whether we have an authenticated user:

if (currentUser.UserId == null)
{
    throw new UnAuthorizedException(
        "unauthorized access"
    );
}
Enter fullscreen mode Exit fullscreen mode

Our ICurrentUser abstraction provides the ID of the currently authenticated user from the JWT claims.

Once we have the user ID, we retrieve the user through UserManager:

var user =
    await userManager.FindByIdAsync(
        currentUser.UserId
    );

if (user == null)
{
    throw new UnAuthorizedException(
        "unAuthorized access"
    );
}
Enter fullscreen mode Exit fullscreen mode

Invalidating the Refresh Token

Once we have the user, we remove the stored refresh token:

user.RefreshToken = null;
user.RefreshTokenExpiryTime = null;

await userManager.UpdateAsync(user);
Enter fullscreen mode Exit fullscreen mode

This is important because simply removing the token from the client is not enough.

If someone has obtained a valid refresh token before logout, they could potentially continue using it.

By removing the refresh token from the database, the server can no longer validate that token.

After logout, the previously stored refresh token can no longer be used to obtain a new access token.

 public async Task<LogoutCommandResponse> Logout(LogoutCommand command)
 {
     if (currentUser.UserId == null)
     {
         throw new UnAuthorizedException("unauthorized access");
     }

     var user = await userManager.FindByIdAsync(currentUser.UserId);

     if(user == null)
     {
         throw new UnAuthorizedException("unAuthorized access");
     }

     user.RefreshToken = null;
     user.RefreshTokenExpiryTime = null;

     await userManager.UpdateAsync(user);

     return new LogoutCommandResponse { };

 }```


Enter fullscreen mode Exit fullscreen mode

Source: dev.to

arrow_back Back to News