Refresh Token in ASP.NET Core Web API using JWT Authentication

Refresh Token in ASP.NET Core Web API using JWT Authentication:

In this article, I will discuss how to implement Refresh Token in ASP.NET Core Web API Application using JWT Authentication. Please read our previous article discussing JWT Authentication in ASP.NET Core Web API. We will work with the same applications we created in our previous article.

What is a Refresh Token in JWT Token-Based Authentication?

A Refresh Token is a credential used to obtain a new access token without requiring the user to log in or re-authenticate again. In JWT-based authentication, access tokens (the JWTs) are typically short-lived. Once the access token expires, clients can no longer access protected resources.

The refresh token is a long-lived token that allows the client to request a new access token without re-authenticating the user. The refresh token itself is not a JWT; it’s usually a random string stored securely on the server side and issued to the client during the initial authentication process.

How Refresh Tokens Work:

The use of refresh tokens enhances security and user experience by allowing sessions to be extended without requiring the user to log in multiple times. For a better understanding of how Refresh Tokens work in Restful Services, please have a look at the following diagram:

What is a Refresh Token in JWT Token-Based Authentication?

The above diagram illustrates the process of using refresh tokens in Web APIs to manage and renew access tokens for authenticated users. Let us understand how it works step by step:

User Authorization Request (Step 1):

The client application (usually a web or mobile app) sends a request to the authorization server to authenticate the user and obtain access to the protected resource. This typically involves submitting the user’s credentials (e.g., username and password) or using OAuth with authorization grants, i.e., external authentication.

Access Token and Refresh Token Issuance (Step 2):

The authorization server validates the user’s credentials. Upon successful validation, the server issues two tokens:

  • Access Token: A short-lived token for accessing protected resources.
  • Refresh Token: A long-lived token to request new access tokens after the access token expires.

Both tokens are sent back to the client application.

Accessing Protected Resources (Step 3-4):

The client uses the access token to access protected resources on the resource server. The resource server validates the access token to ensure it is valid, not expired and signed correctly. The server grants access to the requested resource if the token is valid.

Access Token Expiry (Steps 5-6):

Over time, the access token expires (as it is intentionally short-lived for security reasons). The resource server returns an Invalid Token Error when the client sends an expired or invalid access token. The client can no longer access protected resources using the expired token.

Refreshing the Access Token (Step 7):

The client application sends the refresh token to the authorization server to request a new access token. The refresh token is validated by the authorization server to ensure it is still valid, has not expired, or has been revoked.

New Access Token and Refresh Token Issued (Step 8):

Upon successful validation, the authorization server issues:

  • A new access token to access protected resources.
  • Optionally, a new refresh token to replace the old one (depending on the implementation).

The client stores the new tokens and resumes accessing protected resources.

The refresh token is typically long-lived, while the access token has a shorter expiration time to ensure security. It also limits the exposure of user credentials and reduces the risk associated with long-lived access tokens.

Implementing Refresh Token in ASP.NET Core Web API using JWT Authentication

Let us enhance our existing ASP.NET Core Web API application with Refresh Token functionality. To add refresh token functionality to the Authentication Server project, we will need to:

  • Create a RefreshToken Entity: Define a new model that represents the refresh token, including necessary properties.
  • Creating RefreshToken DTO: Defining data transfer objects for refresh token operations.
  • Modify the Login Endpoint: Update the existing login endpoint to issue both access and refresh tokens.
  • Add a New Endpoint for Refreshing Tokens: Implement an endpoint to issue a new access token when a valid refresh token is presented and revoke the previous refresh token.

Let us proceed with implementing this in our authentication server project. So, please add and update the following to our JWTAuthServer Project.

Adding the RefreshToken Model

First, create a new model to represent refresh tokens. This model will store refresh tokens associated with users, including their expiration, creation, and revocation status. So, create a new class file RefreshToken.cs in the Models folder and copy and paste the following code. The RefreshToken’s Token field is indexed and set to be unique to prevent duplication. It is recommended to include properties to manage the token’s expiration and revocation status.

using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;

namespace JWTAuthServer.Models
{
    [Index(nameof(Token), Name = "IX_Token_Unique", IsUnique =true)]
    public class RefreshToken
    {
        [Key]
        public int Id { get; set; }

        // The refresh token string (should be a secure random string)
        [Required]
        public string Token { get; set; }

        // The user associated with the refresh token
        [Required]
        public int UserId { get; set; }

        [ForeignKey("UserId")]
        public User User { get; set; }

        // The client associated with the refresh token
        [Required]
        public int ClientId { get; set; }

        [ForeignKey(nameof(ClientId))]
        public Client Client { get; set; }

        // Token expiration date
        [Required]
        public DateTime ExpiresAt { get; set; }

        // Indicates if the token has been revoked
        [Required]
        public bool IsRevoked { get; set; } = false;

        // Date when the token was created
        [Required]
        public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

        // Date when the token was revoked
        public DateTime? RevokedAt { get; set; }
    }
}
Key Points:
  • Token Security: Ensure that the Token property stores a securely generated random string. We will hash the token before storing it in the database to prevent token theft from compromising security.
  • Associations: Each RefreshToken is associated with a User and a Client.
Modify ApplicationDbContext

Next, please update the ApplicationDbContext as follows to include the RefreshTokens DbSet. We have configured the CASCADE DELETE behavior so that when a User or Client is deleted, their associated refresh tokens are deleted to maintain data integrity.

using JWTAuthServer.Models;
using Microsoft.EntityFrameworkCore;

namespace JWTAuthServer.Data
{
    public class ApplicationDbContext : DbContext
    {
        // Constructor accepting DbContextOptions and passing them to the base class.
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }

        // Override OnModelCreating to configure entity properties and relationships.
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);

            // Configure the UserRole entity as a join table for User and Role.
            modelBuilder.Entity<UserRole>()
                .HasKey(ur => new { ur.UserId, ur.RoleId }); // Composite primary key.

            //Defines the many-to-many relationship between User and Role.
            modelBuilder.Entity<UserRole>()
                .HasOne(ur => ur.User)
                .WithMany(u => u.UserRoles)
                .HasForeignKey(ur => ur.UserId);

            modelBuilder.Entity<UserRole>()
                .HasOne(ur => ur.Role)
                .WithMany(r => r.UserRoles)
                .HasForeignKey(ur => ur.RoleId);

            // Configure relationships
            // When a User is deleted, their associated refresh tokens are also deleted to maintain data integrity.
            modelBuilder.Entity<RefreshToken>()
                .HasOne(rt => rt.User)
                .WithMany(u => u.RefreshTokens)
                .HasForeignKey(rt => rt.UserId)
                .OnDelete(DeleteBehavior.Cascade);

            // When a Client is deleted, their associated refresh tokens are also deleted to maintain data integrity.
            modelBuilder.Entity<RefreshToken>()
                .HasOne(rt => rt.Client)
                .WithMany(c => c.RefreshTokens)
                .HasForeignKey(rt => rt.ClientId)
                .OnDelete(DeleteBehavior.Cascade);

            // Seed initial data for Roles, Users, Clients, and UserRoles.
            modelBuilder.Entity<Role>().HasData(
                new Role { Id = 1, Name = "Admin", Description = "Admin Role" },
                new Role { Id = 2, Name = "User", Description = "User Role" }
            );

            modelBuilder.Entity<Client>().HasData(
                new Client
                {
                    Id = 1,
                    ClientId = "Client1",
                    Name = "Client Application 1",
                    ClientURL = "https://client1.com"
                },
                new Client
                {
                    Id = 2,
                    ClientId = "Client2",
                    Name = "Client Application 2",
                    ClientURL = "https://client2.com"
                }
            );
        }

        // DbSet representing the Users table.
        public DbSet<User> Users { get; set; }

        // DbSet representing the Roles table.
        public DbSet<Role> Roles { get; set; }

        // DbSet representing the Clients table.
        public DbSet<Client> Clients { get; set; }

        // DbSet representing the UserRoles join table.
        public DbSet<UserRole> UserRoles { get; set; }

        // DbSet representing the SigningKeys table.
        public DbSet<SigningKey> SigningKeys { get; set; }

        // DbSet representing the RefreshTokens table.
        public DbSet<RefreshToken> RefreshTokens { get; set; }
    }
}
Update the User and Client Models to Include Navigation Properties:

As we maintain one-to-many relationships between User and RefreshToken, Client, and RefreshToken, we need to update the User and Client Models to include Navigation Properties for RefreshToken.

User Entity:

Please modify the User entity as follows:

using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
namespace JWTAuthServer.Models
{
    [Index(nameof(Email), Name = "IX_Unique_Email", IsUnique = true)]
    public class User
    {
        [Key]
        public int Id { get; set; }

        [Required]
        [EmailAddress]
        public string Email { get; set; }

        [Required]
        public string Firstname { get; set; }

        public string? Lastname { get; set; }

        [Required]
        [StringLength(100)]
        public string Password { get; set; }

        // Navigation property for many-to-many relationship with Role
        public ICollection<UserRole> UserRoles { get; set; } 
        
        // Navigation property for refresh tokens
        public ICollection<RefreshToken> RefreshTokens { get; set; }
    }
}
Client Entity:

Please modify the Client entity as follows:

using System.ComponentModel.DataAnnotations;
namespace JWTAuthServer.Models
{
    public class Client
    {
        [Key]
        public int Id { get; set; }

        // Unique identifier for the client application.
        [Required]
        [MaxLength(100)]
        public string ClientId { get; set; }

        // Name of the client application.
        [Required]
        [MaxLength(100)]
        public string Name { get; set; }

        // URL for the client application.
        [Required]
        [MaxLength(200)]
        public string ClientURL { get; set; }

        // Navigation property for refresh tokens
        public ICollection<RefreshToken> RefreshTokens { get; set; }
    }
}
Creating DTOs for Refresh Token Operations

Define data transfer objects to handle refresh token requests and responses.

RefreshTokenRequestDTO

Create a new class file named RefreshTokenRequestDTO.cs in the DTOs Folder, and then copy and paste the following code. The Request DTO Captures the RefreshToken and ClientId sent by the client when requesting a new access token.

using System.ComponentModel.DataAnnotations;
namespace JWTAuthServer.DTOs
{
    public class RefreshTokenRequestDTO
    {
        [Required]
        public string RefreshToken { get; set; }

        [Required]
        public string ClientId { get; set; }
    }
}
RefreshTokenResponseDTO

Create a new class file named TokenResponseDTO.cs in the DTOs Folder, and then copy and paste the following code. The Response DTO returns the client a new Access Token and Refresh Token. We can now use this DTO in the login and refresh token endpoint.

namespace JWTAuthServer.DTOs
{
    public class TokenResponseDTO
    {
        public string Token { get; set; }
        public string RefreshToken { get; set; }
    }
}
Modifying the AuthController to Issue Refresh Tokens

Next, we need to update the AuthController to generate and return a refresh token upon successful authentication. We also implement a new endpoint that allows clients to obtain a new access token using a valid refresh token. The previous refresh token will be revoked to prevent reuse. The following code is self-explained, so please read the comment lines for a better understanding:

using JWTAuthServer.Data;
using JWTAuthServer.DTOs;
using JWTAuthServer.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
namespace JWTAuthServer.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class AuthController : ControllerBase
{
// Private fields to hold the configuration and database context
// Holds configuration settings from appsettings.json or environment variables
private readonly IConfiguration _configuration;
// Database context for interacting with the database
private readonly ApplicationDbContext _context; 
// Constructor that injects IConfiguration and ApplicationDbContext via dependency injection
public AuthController(IConfiguration configuration, ApplicationDbContext context)
{
// Assign the injected IConfiguration to the private field
_configuration = configuration;
// Assign the injected ApplicationDbContext to the private field
_context = context; 
}
// Define the Login endpoint that responds to POST requests at 'api/Auth/Login'
[HttpPost("Login")]
public async Task<IActionResult> Login([FromBody] LoginDTO loginDto)
{
// Validate the incoming model based on data annotations in LoginDTO
if (!ModelState.IsValid)
{
// If the model is invalid, return a 400 Bad Request with validation errors
return BadRequest(ModelState);
}
// Query the Clients table to verify if the provided ClientId exists
var client = _context.Clients
.FirstOrDefault(c => c.ClientId == loginDto.ClientId);
// If the client does not exist, return a 401 Unauthorized response
if (client == null)
{
return Unauthorized("Invalid client credentials.");
}
// Retrieve the user from the Users table by matching the email (case-insensitive)
// Also include the UserRoles and associated Roles for later use
var user = await _context.Users
.Include(u => u.UserRoles) // Include the UserRoles navigation property
.ThenInclude(ur => ur.Role) // Then include the Role within each UserRole
.FirstOrDefaultAsync(u => u.Email.ToLower() == loginDto.Email.ToLower());
// If the user does not exist, return a 401 Unauthorized response
if (user == null)
{
// For security reasons, avoid specifying whether the client or user was invalid
return Unauthorized("Invalid credentials.");
}
// Verify the provided password against the stored hashed password using BCrypt
bool isPasswordValid = BCrypt.Net.BCrypt.Verify(loginDto.Password, user.Password);
// If the password is invalid, return a 401 Unauthorized response
if (!isPasswordValid)
{
// Again, avoid specifying whether the client or user was invalid
return Unauthorized("Invalid credentials.");
}
// At this point, authentication is successful. Proceed to generate a JWT token.
var token = GenerateJwtToken(user, client);
// Generate Refresh Token
var refreshToken = GenerateRefreshToken();
// Hash the refresh token before storing
var hashedRefreshToken = HashToken(refreshToken);
// Create RefreshToken entity
var refreshTokenEntity = new RefreshToken
{
Token = hashedRefreshToken,
UserId = user.Id,
ClientId = client.Id,
//Refresh tokens are set to expire after 7 days (you can adjust this as needed).
ExpiresAt = DateTime.UtcNow.AddDays(7), 
CreatedAt = DateTime.UtcNow,
IsRevoked = false
};
// The hashed refresh token, along with associated user and client information, is stored in the RefreshTokens table.
_context.RefreshTokens.Add(refreshTokenEntity);
await _context.SaveChangesAsync();
// Return both tokens to the client
return Ok(new TokenResponseDTO
{
Token = token,
RefreshToken = refreshToken
});
}
[HttpPost("RefreshToken")]
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequestDTO requestDto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
// Hash the incoming refresh token to compare with stored hash
var hashedToken = HashToken(requestDto.RefreshToken);
// Check if the refresh token exists and matches the provided ClientId.
// Retrieve the refresh token from the database
var storedRefreshToken = await _context.RefreshTokens
.Include(rt => rt.User)
.ThenInclude(u => u.UserRoles)
.ThenInclude(ur => ur.Role)
.Include(rt => rt.Client)
.FirstOrDefaultAsync(rt => rt.Token == hashedToken && rt.Client.ClientId == requestDto.ClientId);
if (storedRefreshToken == null)
{
return Unauthorized("Invalid refresh token.");
}
// Ensure the token hasn't been revoked.
if (storedRefreshToken.IsRevoked)
{
return Unauthorized("Refresh token has been revoked.");
}
// Ensure the token hasn't expired.
if (storedRefreshToken.ExpiresAt < DateTime.UtcNow)
{
return Unauthorized("Refresh token has expired.");
}
// Retrieve the user and client
var user = storedRefreshToken.User;
var client = storedRefreshToken.Client;
// The existing refresh token is marked as revoked to prevent reuse.
storedRefreshToken.IsRevoked = true;
storedRefreshToken.RevokedAt = DateTime.UtcNow;
// Generate a new refresh token
var newRefreshToken = GenerateRefreshToken();
var hashedNewRefreshToken = HashToken(newRefreshToken);
var newRefreshTokenEntity = new RefreshToken
{
Token = hashedNewRefreshToken,
UserId = user.Id,
ClientId = client.Id,
ExpiresAt = DateTime.UtcNow.AddDays(7), // Adjust as needed
CreatedAt = DateTime.UtcNow,
IsRevoked = false
};
// Store the new refresh token
_context.RefreshTokens.Add(newRefreshTokenEntity);
// Generate new JWT access token
var newJwtToken = GenerateJwtToken(user, client);
// Save changes to the database
await _context.SaveChangesAsync();
// Return the new tokens to the client
return Ok(new TokenResponseDTO
{
Token = newJwtToken,
RefreshToken = newRefreshToken
});
}
// Private method responsible for generating a JWT token for an authenticated user
private string GenerateJwtToken(User user, Client client)
{
// Retrieve the active signing key from the SigningKeys table
var signingKey = _context.SigningKeys.FirstOrDefault(k => k.IsActive);
// If no active signing key is found, throw an exception
if (signingKey == null)
{
throw new Exception("No active signing key available.");
}
// Convert the Base64-encoded private key string back to a byte array
var privateKeyBytes = Convert.FromBase64String(signingKey.PrivateKey);
// Create a new RSA instance for cryptographic operations
var rsa = RSA.Create();
// Import the RSA private key into the RSA instance
rsa.ImportRSAPrivateKey(privateKeyBytes, out _);
// Create a new RsaSecurityKey using the RSA instance
var rsaSecurityKey = new RsaSecurityKey(rsa)
{
// Assign the Key ID to link the JWT with the correct public key
KeyId = signingKey.KeyId 
};
// Define the signing credentials using the RSA security key and specifying the algorithm
var creds = new SigningCredentials(rsaSecurityKey, SecurityAlgorithms.RsaSha256);
// Initialize a list of claims to include in the JWT
var claims = new List<Claim>
{
// Subject (sub) claim with the user's ID
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
// JWT ID (jti) claim with a unique identifier for the token
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
// Name claim with the user's first name
new Claim(ClaimTypes.Name, user.Firstname),
// NameIdentifier claim with the user's email
new Claim(ClaimTypes.NameIdentifier, user.Email),
// Email claim with the user's email
new Claim(ClaimTypes.Email, user.Email)
};
// Iterate through the user's roles and add each as a Role claim
foreach (var userRole in user.UserRoles)
{
claims.Add(new Claim(ClaimTypes.Role, userRole.Role.Name));
}
// Define the JWT token's properties, including issuer, audience, claims, expiration, and signing credentials
var tokenDescriptor = new JwtSecurityToken(
issuer: _configuration["Jwt:Issuer"], // The token issuer, typically your application's URL
audience: client.ClientURL, // The intended recipient of the token, typically the client's URL
claims: claims, // The list of claims to include in the token
expires: DateTime.UtcNow.AddHours(1), // Token expiration time set to 1 hour from now
signingCredentials: creds // The credentials used to sign the token
);
// Create a JWT token handler to serialize the token
var tokenHandler = new JwtSecurityTokenHandler();
// Serialize the token to a string
var token = tokenHandler.WriteToken(tokenDescriptor);
// Return the serialized JWT token
return token;
}
// Helper method to generate a secure random refresh token
private string GenerateRefreshToken()
{
//A secure random string is generated using RandomNumberGenerator
var randomNumber = new byte[64];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(randomNumber);
return Convert.ToBase64String(randomNumber);
}
}
// Helper method to hash tokens before storing them
private string HashToken(string token)
{
//The refresh token is hashed using SHA256 before storing it in the database to prevent token theft from compromising security.
using var sha256 = SHA256.Create();
var hashedBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(token));
return Convert.ToBase64String(hashedBytes);
}
}
}
Database Migration

Next, we need to generate the Migration again and update the database schema. So, open the Package Manager Console and Execute the Add-Migration and Update-Database commands as follows.

Refresh Token in ASP.NET Core Web API using JWT Authentication

With this, our JWTAuthDb database should have been added with the new RefreshTokens table, as shown in the below image:

How to Implement Refresh Token in ASP.NET Core Web API using JWT Authentication

Testing the Implementation

Let us see how we can test both Login and Refresh Token endpoints:

Testing the Login Endpoint

HTTP Method: POST
Endpoint: /api/Auth/Login
Headers:
Content-Type: application/json
Body (Raw JSON):

{
"Email": "pranaya@example.com",
"Password": "Password@123",
"ClientId": "Client1"
}

Expected Response:
You will get a response containing a valid JWT Token and a Refresh Token, as shown below, if the provided credentials are valid.

{
"Token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"RefreshToken": "randomlyGeneratedRefreshTokenString..."
}
Testing the RefreshToken Endpoint

HTTP Method: POST
Endpoint: /api/Auth/RefreshToken
Content-Type: application/json
Body (Raw JSON):

{
"RefreshToken": "randomlyGeneratedRefreshTokenString...",
"ClientId": "Client1"
}

Expected Response:
Here also, you will get a response containing a valid JWT Token and a new Refresh Token, as shown below, if the provided Refresh Token and ClientId are valid.

{
"Token": "newlyGeneratedAccessToken...",
"RefreshToken": "newlyGeneratedRefreshTokenString..."
}

Testing the Refresh Token using Client Application:

We have also created one .NET Console application where we generate the access token, and using that access token, we are consuming the resources services that require a valid JWT token. Let us see how we can use the Refresh Token endpoint from our client application.

To enhance our .NET Console Client Application to support Refresh Tokens, we will modify the existing ResourceClient application implementation to handle token expiration. This involves:

  • Storing Both Access and Refresh Tokens: After authentication, store both tokens securely.
  • Handling Token Expiration: Detect when the access token has expired and use the refresh token to obtain a new access token.
  • Retrying Failed Requests: Automatically retry failed requests after refreshing the token.
Implementing Token Storage

We will create a simple TokenStorage class to manage the access and refresh tokens within the application’s lifecycle. Create a new file named TokenStorage.cs in the project root or a suitable folder, then copy and paste the following code.

namespace ResourceClient
{
public class TokenStorage
{
// Stores the current access token used for authenticated API requests.
public string AccessToken { get; set; } = string.Empty;
// Stores the current refresh token used to obtain new access tokens.
public string RefreshToken { get; set; } = string.Empty;
// Stores the client identifier associated with the tokens.
public string ClientId { get; set; } = string.Empty;
}
}
LoginResponseDTO

Create a new file named LoginResponseDTO.cs in the project root or a suitable folder, such as DTOs, and then copy and paste the following code. This DTO stores both the access and refresh tokens upon successful login.

namespace ResourceClient.DTOs
{
public class LoginResponseDTO
{
public string Token { get; set; }
public string RefreshToken { get; set; }
}
}
RefreshTokenRequestDTO

Create a new file named RefreshTokenRequestDTO.cs inside the DTOs folder and then copy and paste the following code. We will use this DTO to send a request to get the access token based on the refresh token.

namespace ResourceClient.DTOs
{
public class RefreshTokenRequestDTO
{
public string RefreshToken { get; set; }
public string ClientId { get; set; }
}
}
RefreshTokenResponseDTO

Create a new file named RefreshTokenResponseDTO.cs inside the DTOs folder. This DTO will hold the response received from the Refresh Token API endpoint.

namespace ResourceClient.DTOs
{
public class RefreshTokenResponseDTO
{
public string Token { get; set; }
public string RefreshToken { get; set; }
}
}
Modify the Program Class:

So, please modify the Program class of our ResourceClient application as follows. We need to modify the resource consumption method to handle scenarios where the access token has expired. Upon receiving a 401 Unauthorized response, attempt to refresh the token and retry the request. The following code is self-explained, so please read the comment lines for a better understanding:

using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using ResourceClient.DTOs;
namespace ResourceClient
{
public class Program
{
// Configuration settings
private static readonly string AuthServerBaseUrl = "https://localhost:7022"; // Authentication Server URL
private static readonly string ResourceServerBaseUrl = "https://localhost:7267"; // Replace with your Resource Server's URL and port
private static readonly string ClientId = "Client1"; // Must match a valid ClientId in Auth Server
private static readonly string UserEmail = "pranaya@example.com"; // Replace with registered user's email
private static readonly string UserPassword = "Password@123"; // Replace with registered user's password
// Token storage instance
private static readonly TokenStorage tokenStorage = new TokenStorage();
// HttpClient instance (shared)
private static readonly HttpClient httpClient = new HttpClient();
static async Task Main(string[] args)
{
try
{
// Step 1: Authenticate and obtain JWT token and Refresh Token
var loginSuccess = await AuthenticateAsync(UserEmail, UserPassword, ClientId);
if (!loginSuccess)
{
Console.WriteLine("Authentication failed. Exiting...");
return;
}
Console.WriteLine("Authentication successful. Tokens obtained.\n");
// Step 2: Consume Resource Server's ProductsController endpoints
await ConsumeResourceServerAsync();
}
catch (Exception ex)
{
Console.WriteLine($"An error occurred: {ex.Message}");
}
finally
{
httpClient.Dispose();
}
}
// Authenticates the user with the Authentication Server and retrieves JWT and Refresh tokens.
private static async Task<bool> AuthenticateAsync(string email, string password, string clientId)
{
var loginUrl = $"{AuthServerBaseUrl}/api/Auth/Login";
var loginData = new
{
Email = email,
Password = password,
ClientId = clientId
};
var content = new StringContent(JsonSerializer.Serialize(loginData), Encoding.UTF8, "application/json");
Console.WriteLine("Sending authentication request...");
var response = await httpClient.PostAsync(loginUrl, content);
if (!response.IsSuccessStatusCode)
{
Console.WriteLine($"Authentication failed with status code: {response.StatusCode}");
var errorContent = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Error: {errorContent}\n");
return false;
}
var responseContent = await response.Content.ReadAsStringAsync();
var loginResponse = JsonSerializer.Deserialize<LoginResponseDTO>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (loginResponse != null && !string.IsNullOrEmpty(loginResponse.Token) && !string.IsNullOrEmpty(loginResponse.RefreshToken))
{
tokenStorage.AccessToken = loginResponse.Token;
tokenStorage.RefreshToken = loginResponse.RefreshToken;
tokenStorage.ClientId = clientId;
return true;
}
Console.WriteLine("Token not found in the authentication response.\n");
return false;
}
// Consumes the Resource Server's ProductsController endpoints using the JWT token.
private static async Task ConsumeResourceServerAsync()
{
// Set the Authorization header with the Bearer token
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenStorage.AccessToken);
// Create a new product
var newProduct = new
{
Name = "Smartphone",
Description = "A high-end smartphone with excellent features.",
Price = 999.99
};
Console.WriteLine("Creating a new product...");
var createResponse = await httpClient.PostAsync(
$"{ResourceServerBaseUrl}/api/Products/Add",
new StringContent(JsonSerializer.Serialize(newProduct), Encoding.UTF8, "application/json"));
if (createResponse.IsSuccessStatusCode)
{
var createdProductJson = await createResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Product created successfully: {createdProductJson}\n");
}
else if (createResponse.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
Console.WriteLine("Access token expired or invalid. Attempting to refresh token...");
var refreshSuccess = await RefreshTokenAsync();
if (refreshSuccess)
{
// Retry the request with the new token
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenStorage.AccessToken);
createResponse = await httpClient.PostAsync(
$"{ResourceServerBaseUrl}/api/Products/Add",
new StringContent(JsonSerializer.Serialize(newProduct), Encoding.UTF8, "application/json"));
if (createResponse.IsSuccessStatusCode)
{
var createdProductJson = await createResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Product created successfully after token refresh: {createdProductJson}\n");
}
else
{
Console.WriteLine($"Failed to create product after token refresh. Status Code: {createResponse.StatusCode}");
var errorContent = await createResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Error: {errorContent}\n");
}
}
else
{
Console.WriteLine("Failed to refresh token. Exiting...");
return;
}
}
else
{
Console.WriteLine($"Failed to create product. Status Code: {createResponse.StatusCode}");
var errorContent = await createResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Error: {errorContent}\n");
}
// Step Retrieve all products
Console.WriteLine("Retrieving all products...");
var getAllResponse = await httpClient.GetAsync($"{ResourceServerBaseUrl}/api/Products/GetAll");
if (getAllResponse.IsSuccessStatusCode)
{
var productsJson = await getAllResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Products: {productsJson}\n");
}
else if (getAllResponse.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
Console.WriteLine("Access token expired or invalid. Attempting to refresh token...");
var refreshSuccess = await RefreshTokenAsync();
if (refreshSuccess)
{
// Retry the request with the new token
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenStorage.AccessToken);
getAllResponse = await httpClient.GetAsync($"{ResourceServerBaseUrl}/api/Products/GetAll");
if (getAllResponse.IsSuccessStatusCode)
{
var productsJson = await getAllResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Products after token refresh: {productsJson}\n");
}
else
{
Console.WriteLine($"Failed to retrieve products after token refresh. Status Code: {getAllResponse.StatusCode}");
var errorContent = await getAllResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Error: {errorContent}\n");
}
}
else
{
Console.WriteLine("Failed to refresh token. Exiting...");
return;
}
}
else
{
Console.WriteLine($"Failed to retrieve products. Status Code: {getAllResponse.StatusCode}");
var errorContent = await getAllResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Error: {errorContent}\n");
}
// Step Retrieve a specific product by ID
Console.WriteLine("Retrieving product with ID 1...");
var getByIdResponse = await httpClient.GetAsync($"{ResourceServerBaseUrl}/api/Products/GetById/1");
if (getByIdResponse.IsSuccessStatusCode)
{
var productJson = await getByIdResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Product Details: {productJson}\n");
}
else if (getByIdResponse.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
Console.WriteLine("Access token expired or invalid. Attempting to refresh token...");
var refreshSuccess = await RefreshTokenAsync();
if (refreshSuccess)
{
// Retry the request with the new token
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenStorage.AccessToken);
getByIdResponse = await httpClient.GetAsync($"{ResourceServerBaseUrl}/api/Products/GetById/1");
if (getByIdResponse.IsSuccessStatusCode)
{
var productJson = await getByIdResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Product Details after token refresh: {productJson}\n");
}
else
{
Console.WriteLine($"Failed to retrieve product after token refresh. Status Code: {getByIdResponse.StatusCode}");
var errorContent = await getByIdResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Error: {errorContent}\n");
}
}
else
{
Console.WriteLine("Failed to refresh token. Exiting...");
return;
}
}
else
{
Console.WriteLine($"Failed to retrieve product. Status Code: {getByIdResponse.StatusCode}");
var errorContent = await getByIdResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Error: {errorContent}\n");
}
// Step Update a product
var updatedProduct = new
{
Name = "Smartphone Pro",
Description = "An upgraded smartphone with enhanced features.",
Price = 1199.99
};
Console.WriteLine("Updating product with ID 1...");
var updateResponse = await httpClient.PutAsync(
$"{ResourceServerBaseUrl}/api/Products/Update/1",
new StringContent(JsonSerializer.Serialize(updatedProduct), Encoding.UTF8, "application/json"));
if (updateResponse.IsSuccessStatusCode || updateResponse.StatusCode == System.Net.HttpStatusCode.NoContent)
{
Console.WriteLine("Product updated successfully.\n");
}
else if (updateResponse.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
Console.WriteLine("Access token expired or invalid. Attempting to refresh token...");
var refreshSuccess = await RefreshTokenAsync();
if (refreshSuccess)
{
// Retry the request with the new token
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenStorage.AccessToken);
updateResponse = await httpClient.PutAsync(
$"{ResourceServerBaseUrl}/api/Products/Update/1",
new StringContent(JsonSerializer.Serialize(updatedProduct), Encoding.UTF8, "application/json"));
if (updateResponse.IsSuccessStatusCode || updateResponse.StatusCode == System.Net.HttpStatusCode.NoContent)
{
Console.WriteLine("Product updated successfully after token refresh.\n");
}
else
{
Console.WriteLine($"Failed to update product after token refresh. Status Code: {updateResponse.StatusCode}");
var errorContent = await updateResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Error: {errorContent}\n");
}
}
else
{
Console.WriteLine("Failed to refresh token. Exiting...");
return;
}
}
else
{
Console.WriteLine($"Failed to update product. Status Code: {updateResponse.StatusCode}");
var errorContent = await updateResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Error: {errorContent}\n");
}
// Step Delete a product
Console.WriteLine("Deleting product with ID 1...");
var deleteResponse = await httpClient.DeleteAsync($"{ResourceServerBaseUrl}/api/Products/Delete/1");
if (deleteResponse.IsSuccessStatusCode || deleteResponse.StatusCode == System.Net.HttpStatusCode.NoContent)
{
Console.WriteLine("Product deleted successfully.\n");
}
else if (deleteResponse.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
Console.WriteLine("Access token expired or invalid. Attempting to refresh token...");
var refreshSuccess = await RefreshTokenAsync();
if (refreshSuccess)
{
// Retry the request with the new token
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenStorage.AccessToken);
deleteResponse = await httpClient.DeleteAsync($"{ResourceServerBaseUrl}/api/Products/Delete/1");
if (deleteResponse.IsSuccessStatusCode || deleteResponse.StatusCode == System.Net.HttpStatusCode.NoContent)
{
Console.WriteLine("Product deleted successfully after token refresh.\n");
}
else
{
Console.WriteLine($"Failed to delete product after token refresh. Status Code: {deleteResponse.StatusCode}");
var errorContent = await deleteResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Error: {errorContent}\n");
}
}
else
{
Console.WriteLine("Failed to refresh token. Exiting...");
return;
}
}
else
{
Console.WriteLine($"Failed to delete product. Status Code: {deleteResponse.StatusCode}");
var errorContent = await deleteResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Error: {errorContent}\n");
}
}
// Refreshes the access token using the refresh token
private static async Task<bool> RefreshTokenAsync()
{
var refreshUrl = $"{AuthServerBaseUrl}/api/Auth/RefreshToken";
var refreshData = new RefreshTokenRequestDTO
{
RefreshToken = tokenStorage.RefreshToken,
ClientId = tokenStorage.ClientId
};
var content = new StringContent(JsonSerializer.Serialize(refreshData), Encoding.UTF8, "application/json");
Console.WriteLine("Attempting to refresh access token...");
var response = await httpClient.PostAsync(refreshUrl, content);
if (!response.IsSuccessStatusCode)
{
Console.WriteLine($"Token refresh failed with status code: {response.StatusCode}");
var errorContent = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Error: {errorContent}\n");
return false;
}
var responseContent = await response.Content.ReadAsStringAsync();
var refreshResponse = JsonSerializer.Deserialize<RefreshTokenResponseDTO>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (refreshResponse != null && !string.IsNullOrEmpty(refreshResponse.Token) && !string.IsNullOrEmpty(refreshResponse.RefreshToken))
{
tokenStorage.AccessToken = refreshResponse.Token;
tokenStorage.RefreshToken = refreshResponse.RefreshToken;
Console.WriteLine("Access token refreshed successfully.\n");
return true;
}
Console.WriteLine("Failed to parse refresh token response.\n");
return false;
}
}
}
Testing the Refresh Token with Client Application:

First, run the Authentication Server application, then run the Resource Server Application, and then only run the Client Application and you should get the following result:

Testing the Refresh Token with Client Application

Why Not Use Long-Lived Access Tokens? The Advantages of Refresh Tokens in Web APIs

When implementing JWT (JSON Web Token) authentication in your ASP.NET Core Web API, a common question arises: Why not issue long-lived access tokens instead of using refresh tokens? The answer lies in balancing security, flexibility, and user experience. Here are the primary reasons why incorporating refresh tokens is beneficial:

Dynamic Updates to Access Token Claims
Challenge with Long-Lived Access Tokens:

Access tokens are self-contained, meaning they carry all the necessary information (known as claims) about the authenticated user at the time of issuance. For instance, suppose you issue an access token with a validity of one month to a user named Anurag, assigning him the role of “User”. This token embeds Anurag’s role information.

However, consider a scenario three days later where Anurag’s role changes to “Admin”. The existing long-lived access token still holds the outdated role information (“User”) and cannot be updated to reflect this change. So, you need to ask him to re-authenticate himself again so that the Authorization server will add the updated information to the newly generated access token. This is not feasible in most of the cases. You might not be able to reach the users who have already obtained the long-lived access tokens.

Solution with Refresh Tokens:

You can overcome this limitation by issuing short-lived access tokens (e.g., valid for 30 minutes) alongside long-lived refresh tokens (e.g., valid for one year). When Anurag’s role changes:

  • Short-Lived Access Token: The current access token will expire shortly (e.g., in 30 minutes).
  • Using Refresh Token: Anurag’s client application can use the refresh token to request a new access token.
  • Updated Claims: The authorization server generates a new access token that includes Anurag’s updated role (“Admin”), ensuring that his permissions are current without requiring immediate re-authentication.

This approach ensures that access token claims remain up-to-date and reflect any changes in user roles or permissions.

Efficient Revocation of Access
Challenge with Long-Lived Access Tokens:

Once the user obtains the long-lived access token, then he will be able to access the server resources as long as his access token has not expired. There is no standard way to revoke the access tokens unless and until the Authorization Server implements some custom logic to store the generated access token in a database and needs to do database checks with each and every request. This will add complexity and overhead, potentially impacting performance.

Solution with Refresh Tokens:

Refresh tokens offer a more manageable revocation mechanism:

  • Single Point of Control: Since refresh tokens are stored server-side (e.g., in a database), the database or system admin can revoke access by deleting the refresh token identifier from the database at any time.
  • Impact of Revocation: Once a refresh token is revoked, the user can no longer obtain new access tokens using the deleted refresh tokens. Existing access tokens will eventually expire (e.g., after 30 minutes) and cannot be refreshed, effectively terminating access.

This method provides a secure and efficient way to revoke user access without the need to track every access token issued.

Enhanced User Experience Without Frequent Re-Authentication
Challenge with Long-Lived Access Tokens:

If you solely depend on access tokens with extended lifespans (e.g., one month), you might avoid frequent re-authentication, but this approach compromises security and flexibility. Moreover, maintaining session integrity becomes problematic in environments where token revocation is not straightforward.

Solution with Refresh Tokens:

Incorporating refresh tokens significantly improves the user experience by minimizing the need for users to re-enter their credentials:

  • Seamless Token Renewal: Users authenticate once, receiving both access and refresh tokens. The client application can automatically use the refresh token to obtain new access tokens as needed without interrupting the user.
  • Persistent Sessions: Refresh tokens can have a longer lifespan (e.g., one year), allowing users to maintain authenticated sessions for extended periods without frequent logins.
  • Reduced Credential Exposure: By avoiding repeated transmission of usernames and passwords, the risk of credential compromise is minimized.

In Web Applications, mobile apps, and single-page applications (SPAs), refresh tokens provide a seamless experience by reducing the need for users to log in frequently while maintaining the security and flexibility of JWT-based authentication.

In the next article, I will discuss implementing the Logout endpoint to Revoke Refresh Tokens in JWT-Based Token Authentication. Here, in this article, I explain how to Implement a Refresh Token in JWT Token-Based Authentication in an ASP.NET Core Web API Application with an Example. I hope you enjoy this article on implementing a Refresh Token in JWT Token-Based Authentication in an ASP.NET Core Web API application.

Leave a Reply

Your email address will not be published. Required fields are marked *