JWT Authentication in ASP.NET Core

JWT Authentication in ASP.NET Core Web API

In this article, I will discuss how to implement JSON Web Token (JWT)- based Token Authentication in ASP.NET Core Web API Applications, with Examples. Please read our previous article, which discusses Role-Based Basic Authentication in ASP.NET Core Web API. JWT is a popular method for securely identifying users by sending a token instead of credentials with every request. It helps protect our API by allowing only authorized users to access data and services. In this post, we will cover the basics of JWT, explain how it works, and provide a step-by-step guide to implementing it in an ASP.NET Core Web API project.

Why Do We Need Token-Based Authentication in ASP.NET Core Web API?

ASP.NET Core Web API is a powerful, modern framework developed by Microsoft for building HTTP-based services on the .NET Core platform. These Web APIs act as backend services that can be consumed by a wide variety of clients, including but not limited to:

  • Web Browsers: JavaScript-based Single Page Applications (SPAs) like Angular, React, or Blazor.
  • Mobile Applications: Android and iOS apps that communicate with backend services.
  • Desktop Applications: Windows, Mac, or cross-platform desktop software.
  • IoT Devices: Smart devices and sensors that interact with cloud APIs for data and control.

For a better understanding, please have a look at the following image:

Why Do We Need Token-Based Authentication in ASP.NET Core Web API?

As the adoption of Web APIs grows rapidly across industries, the demand for building secure, scalable, and maintainable APIs has become critical. Simply developing functional APIs is not enough; securing these services is essential to protect sensitive data and maintain trust.

The Importance of Security in Web APIs

Web APIs often expose critical business logic and sensitive data. Without proper security mechanisms:

  • Unauthorized Access: Attackers or malicious users may gain unauthorized access to private resources or sensitive data.
  • Data Breach Risks: Sensitive information, including user details, financial data, or business secrets, may be compromised.
  • Service Abuse: Unauthorized users may exploit APIs, leading to data corruption, denial of service, or fraud.

For a better understanding, please have a look at the following image:

The Importance of Security in Web APIs

Therefore, enforcing authentication (verifying who the user or client is) and authorization (what they are allowed to do) is a foundational requirement for any Web API. One of the most preferred approaches for securing Web APIs is Token-Based Authentication.

What is JWT-Based Token Authentication?

JSON Web Token (JWT) is a widely used and standardized method for token-based authentication in modern web applications and APIs. It enables secure information exchange between parties through a compact, self-contained token that can be easily verified without requiring repeated database queries. JWTs are widely used because they are:

  • Compact: The token is small in size, allowing it to be easily sent through URLs or HTTP headers (e.g., Authorization: Bearer <token>).
  • Self-Contained: All the required information about the user (or subject), such as issuer, user ID, roles, and custom data, is stored within the token itself, reducing the need for repeated database lookups.
  • Easy to verify: Tokens can be validated quickly using the signature mechanism.

In an ASP.NET Core Web API, once a user logs in and proves their credentials, your server issues a JWT. On subsequent requests, the client sends that token in the Authorization: Bearer <token> header. Your API middleware reads the token, verifies its signature and claims, and, if everything checks out, grants access to protected endpoints.

To see how a JWT looks visually and to experiment with decoding and encoding, you can visit this official JWT debugging tool:

https://jwt.io/

Once you visit the above page, you will see the following.

What is JWT-Based Token Authentication?

Structure of a JWT

As you can see in the above page, a typical JWT token consists of three parts, each separated by a dot (.):

  • Header
  • Payload
  • Signature

These three sections are encoded separately using Base64Url encoding and concatenated with dots (.) in between. Example: <Base64Url-Header>.<Base64Url-Payload>.<Base64Url-Signature>.

Let us proceed and understand these three parts in detail before implementing them in our ASP.NET Core Web API Application.

JWT Header:

The header provides information about how the JWT is constructed, especially which algorithm was used to sign it. It is primarily specifying:

  • The type of token (usually JWT), and
  • The signing algorithm used to create the token’s signature (e.g., HS256 for HMAC SHA256, RS256 for RSA SHA256).

The header is a simple JSON object. For example:

{
  "alg": "HS256",
  "typ": "JWT"
}
Explanation of Fields:
  • alg (Algorithm): Defines the cryptographic algorithm used to sign the token.
  • HMAC (Symmetric Key): Uses the same secret key for both signing and verification.
  • RSA (Asymmetric Key): Uses a private key to sign the token and a public key to verify it.
  • typ (Type): Indicates the type of token, which is always “JWT” for JSON Web Tokens.

When your API receives a token, it looks at the alg to know how to verify the signature. This JSON object is then encoded using Base64Url encoding to become the first part of the JWT. It looks like a string such as: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

JWT Payload:

The payload carries the claims, which are pieces of information about the user or entity, as well as any additional metadata the server wants to include. Claims can be:

Registered claims: Standardized fields defined by the JWT specification to ensure interoperability. Examples include:

  • iss (Issuer): The entity that issued the token (e.g., your authentication server URL).
  • sub (Subject): The identifier for the user (e.g., User ID, Username, or Email).
  • aud (Audience): Intended recipient(s) of the token, such as your frontend app.
  • exp (Expiration Time): When the token expires, expressed as Unix time (seconds since Epoch).
  • iat (Issued At): When the token was issued.
  • jti (JWT ID): A unique identifier for the token to prevent reuse.

Custom claims: Application-specific data such as user roles, permissions, or profile IDs.

Example Payload JSON:

{
  "iss": "http://example.com",
  "sub": "user12345",
  "aud": "http://exampleapp.com",
  "exp": 1700001234,
  "iat": 1699990200,
  "jti": "uniqueid123",
  "role": "admin",
  "nickname": "johnny123",
  "profileId": "PID123"
}

Like the header, the payload JSON is Base64Url encoded to become the second part of the JWT. It might look like: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ

Note: Never include sensitive data (such as passwords) within the payload. While the token is signed and protected from tampering, the contents are not encrypted and can be viewed by anyone who has the token.

JWT Signature:

The signature is used to verify that the token was generated by the sender and its content hasn’t been changed. It protects the token from tampering. It is created by:

  • Taking the Base64Url encoded header and payload,
  • Concatenating them with a dot ., and
  • Applying a cryptographic signing algorithm using a secret key or private key (depending on the algorithm).

For example, with the HMAC SHA256 algorithm (HS256), the signature is created as follows:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secretKey
)

The signature is also Base64Url encoded to form the third part of the JWT. An example signature string looks like: SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Final JWT Token:

The full JWT token is the combination of:

  • Header (Base64Url encoded)
  • Payload (Base64Url encoded)
  • Signature (Base64Url encoded)

Separated by dots (.):

The structure looks like this: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ. SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

How JWT Authentication Works in a Typical Client-Server Application

For a better understanding of how it works in ASP.NET Core Web API, please have a look at the following diagram:

How JWT Authentication Works in a Typical Client-Server Application

Let’s break down the authentication flow step by step to understand how these components interact.

Step 1: User Login and Credential Submission

The process starts when the user wants to log in to your application (using a browser, mobile app, etc.). The user enters their credentials (such as email/Username and Password) on the client application. The client sends these credentials in a secure request (usually a secure HTTPS POST request) to the authorization server for authentication, i.e., to verify the user’s identity.

For example, the client sends a POST request to https://auth.example.com/api/login with a JSON body:

{
  "Email": "pranaya.rout@example.com",
  "Password": "mypassword123"
}

Note: Communication should always occur over HTTPS to ensure credentials are encrypted when transmitted over the network.

Step 2: Authorization Server Validates Credentials and Issues Tokens:

The Authorization Server checks the credentials against its user database. If the credentials are correct, the server generates two tokens:

  • Access Token: A short-lived JWT (usually valid from 15 minutes to 1 hour) that the client uses to authenticate itself in API requests to the Resource Server. This token contains claims such as user ID, roles, and expiration time, among others. The Access Token is typically stateless and not stored on the server; its validity is verified by checking its signature and claims.
  • Refresh Token: A longer-lived token (valid for several days or weeks) used to obtain new access tokens without requiring the user to log in again. The refresh token is stored securely and used only when the access token expires. The Refresh Tokens are stored in the database so that they can be revoked if necessary (e.g., upon logout or detection of a compromise).
Why two tokens?

The Access Token is kept short-lived for security; if compromised, it limits exposure. The Refresh Token allows a seamless user experience by renewing access without asking the user to re-enter credentials frequently.

Step 3: Client Receives and Stores Tokens Securely

The client receives both the Access Token and the Refresh Token from the Authorization Server’s response and stores them securely.

  • Web Applications: typically use secure HTTP-only cookies for refresh tokens, and may use memory, localStorage, or sessionStorage for access tokens (each with pros and cons regarding security and XSS protection).
  • Mobile Apps: Might use secure storage areas provided by the OS (e.g., Keychain for iOS, Keystore for Android).

Example response:

{
  "AccessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "RefreshToken": "8xLOxBtZp8"
}

Note: Please note that localStorage/sessionStorage are susceptible to XSS attacks and should be used with caution. Tokens must be stored securely to prevent unauthorized access.

Step 4: Client Makes Authenticated Requests to Resource Server

For any subsequent API calls to protected endpoints, the client includes the Access Token in the HTTP Authorization header using the Bearer scheme.

The Authorization header is the standard method for sending the bearer token. While cookies or URL query parameters can be used, they are generally less secure and not recommended for API authentication.

  • Example HTTP header: Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…

This means the client does not need to send the username and password with every request, just the token. This token tells the Resource Server who the user is and proves their authentication.

Step 5: Resource Server Validates the Token

Upon receiving the request, the Resource Server verifies the Access Token by:

  • Checking the digital signature to ensure the token is authentic and not tampered with.
  • Checking the expiration time (exp claim) to confirm the token is still valid.
  • Validating the issuer (iss) and audience (aud) claims to ensure the token was issued by a trusted authority and intended for this server.
  • Optionally verifying other claims, such as roles or permissions.

If all validations pass, the Resource Server processes the request and returns the requested data or resource.

Step 6: Access Denied or Token Renewal

If the Access Token has expired or is invalid (e.g., revoked, malformed), the Resource Server responds with a 401 Unauthorized status. At this point, the client can use the Refresh Token to request a new Access Token from the Authorization Server without prompting the user to log in again. The client sends a request with the Refresh Token to an endpoint like:

POST https://auth.example.com/api/refresh

Body:

{
  "RefreshToken": "8xLOxBtZp8"
}

The Authorization Server verifies the Refresh Token (checks that it’s valid and not revoked/expired). If valid, it issues a new Access Token and often a new Refresh Token (a process known as refresh token rotation), invalidating the previous Refresh Token in the database. The client can now continue making requests using the new Access Token, without needing to prompt the user to log in again.

Implementing JWT Authentication in ASP.NET Core Web API

JWT (JSON Web Token) authentication in ASP.NET Core Web API typically involves three main components working together to authenticate and authorize users securely:

  • Client: This is the application or device that the user interacts with, such as a web browser, mobile app, or an IoT device.
  • Authorization Server: This server handles the authentication process for users. It verifies the user’s credentials (like username and password), and if valid, issues JWT tokens (Access Token and Refresh Token) to the client.
  • Resource Server: The resource server is the API or backend that hosts the protected resources that the client wants to access. It validates JWTs to ensure requests come from authenticated clients before granting access to any protected data or operations.

In our example, we will create a single Web API project with both an Authentication and a Resource Server. We will create two controllers: one controller will handle the Authorization server functionalities, and another controller will provide API endpoints to be consumed by the client application, protected with JWT Authentication. Let us proceed and implement this step by step.

Creating a New ASP.NET Core Web API Application:

Create a new ASP.NET Core Web API Project named JWTDemo. Once you create the Project, please install the following Packages which are required for Password Hashing, JWT, and Entity Framework Core with SQL Server database:

  • Microsoft.EntityFrameworkCore.SqlServer
  • Microsoft.EntityFrameworkCore.Tools
  • Microsoft.AspNetCore.Authentication.JwtBearer
  • BCrypt.Net-Next

You can install the Package using NuGet Package Manager for the solution or by executing the following command in the Visual Studio Package Manager Console:

  • Install-Package Microsoft.EntityFrameworkCore.SqlServer
  • Install-Package Microsoft.EntityFrameworkCore.Tools
  • Install-Package Microsoft.AspNetCore.Authentication.JwtBearer
  • Install-Package BCrypt.Net-Next
Define Models (Entities)

Models are classes in your application that represent the structure of the data you want to store and work with. In the context of an ASP.NET Core Web API using Entity Framework Core, models define the shape of the database tables and the relationships between them. Each model class corresponds to a table in the database, and the properties of the class map to columns in that table. Models also often include validation rules and data annotations to enforce constraints on the data. First, create a folder named Models in the project root directory where we will create all our entities.

Role.cs

Create a class file named Role.cs within the Models folder, then copy and paste the following code. The Role entity will hold the application roles and will be connected to the User entity through the UserRole join table. This entity represents a Role (e.g. Admin, User) in the system.

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

        // Name of the role (e.g., Admin, User).
        [Required(ErrorMessage = "Role name is required.")]
        [MaxLength(20, ErrorMessage = "Role name cannot exceed 20 characters.")]
        public string Name { get; set; } = null!;

        //Role Description
        public string? Description { get; set; }

        // Navigation property for users assigned this role
        public ICollection<UserRole> UserRoles { get; set; } = new List<UserRole>();
    }
}
User.cs

Create a class file named User.cs within the Models folder, then copy and paste the following code. The User model will hold the user information. This entity represents a user in the system.

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

namespace JWTDemo.Models
{
    [Index(nameof(Email), Name = "IX_Unique_Email", IsUnique = true)]
    public class User
    {
        [Key]
        public int Id { get; set; }

        [Required(ErrorMessage = "Firstname is required.")]
        [MaxLength(50, ErrorMessage = "Firstname cannot exceed 50 characters.")]
        public string Firstname { get; set; } = null!;

        [MaxLength(50, ErrorMessage = "Lastname cannot exceed 50 characters.")]
        public string? Lastname { get; set; }

        [Required(ErrorMessage = "Email is required.")]
        [EmailAddress(ErrorMessage = "Invalid Email Address.")]
        public string Email { get; set; } = null!;

        public bool IsActive { get; set; } = true;

        [Required]
        [MaxLength(100)]
        public string PasswordHash { get; set; } = null!;

        // Navigation property for roles
        public ICollection<UserRole> UserRoles { get; set; } = new List<UserRole>();

        // Navigation property for Refresh Tokens
        public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
    }
}
UserRole.cs

Create a class file named UserRole.cs in the Models folder and then copy and paste the following code. This entity will manage the many-to-many relationship between User and Role.

namespace JWTDemo.Models
{
    public class UserRole
    {
        // Foreign key referencing User.
        public int UserId { get; set; }
        public User User { get; set; } = null!;

        // Foreign key referencing Role.
        public int RoleId { get; set; }
        public Role Role { get; set; } = null!;
    }
}
Client.cs

Create a class file named Client.cs within the Models folder, then copy and paste the following code. This class represents a client that can request tokens from the authorization server, typically identified by the ClientId. This entity represents a client application that consumes the API, useful for client validation.

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

namespace JWTDemo.Models
{
    [Index(nameof(ClientId), Name = "IX_Unique_ClientId", IsUnique = true)]
    public class Client
    {
        [Key]
        public int Id { get; set; }

        // Unique identifier for the client application.
        [Required(ErrorMessage = "Client Identifier is required.")]
        [MaxLength(50)]
        public string ClientId { get; set; } = null!;

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

        [Required(ErrorMessage = "Client Secret is required.")]
        [MaxLength(100)]
        public string ClientSecret { get; set; } = null!;

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

        [Required]
        public bool IsActive { get; set; } = true;
    }
}
RefreshToken.cs

Create a class file named RefreshToken.cs in the Models folder, then copy and paste the following code. This entity stores refresh tokens associated with users, including their expiration dates, creation dates, and revocation statuses. This entity stores refresh tokens issued to users for token renewal.

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

namespace JWTDemo.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; } = null!;

        // Helps invalidate refresh tokens when the associated access token is revoked or suspected compromised.
        [Required]
        public string JwtId { get; set; } = null!;

        // Token Expiration Date
        [Required]
        public DateTime Expires { get; set; }

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

        // Date when the token was revoked
        public DateTime? RevokedAt { get; set; }

        // The user associated with the refresh token
        // Foreign key for the associated user
        public int UserId { get; set; }
        public User User { get; set; } = null!;

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

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

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

        public string? CreatedByIp { get; set; }
    }
}
Define DTOs for Data Transfer

Data Transfer Objects (DTOs) are used to encapsulate data sent between the client and server, ensuring that only necessary information is exposed. First, create a folder named ‘DTOs’ in the project root directory, where we will store all our Data Transfer Objects.

UserRegisterDTO.cs

Create a class file named UserRegisterDTO.cs within the DTOs folder, then copy and paste the following code. This DTO is used for registering a new user.

using System.ComponentModel.DataAnnotations;
namespace JWTDemo.DTOs
{
    public class UserRegisterDTO
    {
        [Required(ErrorMessage = "First name is required.")]
        [MaxLength(50, ErrorMessage = "First name must be less than or equal to 50 characters.")]
        public string Firstname { get; set; } = null!;

        [MaxLength(50, ErrorMessage = "Last name must be less than or equal to 50 characters.")]
        public string? Lastname { get; set; }

        [Required(ErrorMessage = "Password is required.")]
        [MinLength(6, ErrorMessage = "Password must be at least 6 characters.")]
        public string Password { get; set; } = null!;

        [Required(ErrorMessage = "Email is required.")]
        [EmailAddress(ErrorMessage = "Invalid Email Address.")]
        public string Email { get; set; } = null!;
    }
}
UserLoginDTO.cs

Create a class file named UserLoginDTO.cs within the DTOs folder, then copy and paste the following code. This DTO will capture the login information required for validating the user.

using System.ComponentModel.DataAnnotations;
namespace JWTDemo.DTOs
{
    public class UserLoginDTO
    {
        // Email input from the user during login.
        [EmailAddress]
        [Required(ErrorMessage = "Email is required.")]
        [MaxLength(100, ErrorMessage = "Email must be less than or equal to 100 characters.")]
        public string Email { get; set; } = null!;

        // Password input from the user during login.
        [Required(ErrorMessage = "Password is required.")]
        [MinLength(6, ErrorMessage = "Password must be at least 6 characters long.")]
        [MaxLength(100, ErrorMessage = "Password must be less than or equal to 100 characters.")]
        public string Password { get; set; } = null!;

        [Required(ErrorMessage = "ClientId is required.")]
        public string ClientId { get; set; } = null!;
    }
}
AuthResponseDTO.cs

Create a class file named AuthResponseDTO.cs within the DTOs folder, then copy and paste the following code. This DTO will be used to send back tokens and user info after login.

namespace JWTDemo.DTOs
{
    public class AuthResponseDTO
    {
        public string AccessToken { get; set; } = null!;
        public string RefreshToken { get; set; } = null!;
        public DateTime AccessTokenExpiresAt { get; set; }
    }
}
RefreshTokenRequestDTO.cs

Create a class file named RefreshTokenRequestDTO.cs within the DTOs folder, then copy and paste the following code. This DTO is used to request a new access token via a refresh token.

using System.ComponentModel.DataAnnotations;

namespace JWTDemo.DTOs
{
    public class RefreshTokenRequestDTO
    {
        [Required(ErrorMessage = "Refresh Token is required.")]
        public string RefreshToken { get; set; } = null!;

        [Required(ErrorMessage = "Client Id is required.")]
        public string ClientId { get; set; } = null!;
    }
}
Creating EF Core DbContext:

First, create a folder named Data in the Project root directory. Then add a class file named ApplicationDbContext.cs within the Data folder and copy and paste the following code. The EF Core DbContext manages database connections and maps models to tables, including relationship and key configurations. 

using JWTDemo.Models;
using Microsoft.EntityFrameworkCore;

namespace JWTDemo.Data
{
    public class ApplicationDbContext : DbContext
    {
        // Constructor accepting DbContextOptions for configuration, passed to base DbContext
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options) { }

        // Configure entity relationships, keys, and seed data
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);

            // Define composite primary key for UserRole entity (join table for many-to-many)
            // This means the combination of UserId + RoleId uniquely identifies a UserRole record
            modelBuilder.Entity<UserRole>()
                .HasKey(ur => new { ur.UserId, ur.RoleId });

            // Configure relationship: UserRole -> User (many UserRoles belong to one User)
            modelBuilder.Entity<UserRole>()
                .HasOne(ur => ur.User)
                .WithMany(u => u.UserRoles) // User can have many UserRoles
                .HasForeignKey(ur => ur.UserId); // Foreign key in UserRole points to User.Id

            // Configure relationship: UserRole -> Role (many UserRoles belong to one Role)
            modelBuilder.Entity<UserRole>()
                .HasOne(ur => ur.Role)
                .WithMany(r => r.UserRoles) // Role can have many UserRoles
                .HasForeignKey(ur => ur.RoleId); // Foreign key in UserRole points to Role.Id

            // Seed initial Role data in the database
            modelBuilder.Entity<Role>().HasData(
                new Role { Id = 1, Name = "User", Description = "Regular user role" },
                new Role { Id = 2, Name = "Admin", Description = "Administrator role" },
                new Role { Id = 3, Name = "Editor", Description = "Editor Role" }
            );

            // Seed initial Client data for authentication (demo purposes)
            modelBuilder.Entity<Client>().HasData(
                new Client
                {
                    Id = 1,
                    ClientId = "client-app-one", // Unique client identifier used in JWT tokens
                    Name = "Demo Client Application One",
                    ClientSecret = "fPXxcJw8TW5sA+S4rl4tIPcKk+oXAqoRBo+1s2yjUS4=", // Base64-encoded secret key
                    ClientURL = "https://clientappone.example.com", // Used as Audience in JWT validation
                    IsActive = true // Active client flag
                },
                new Client
                {
                    Id = 2,
                    ClientId = "client-app-two",
                    Name = "Demo Client Application Two",
                    ClientSecret = "UkY2JEdtWqKFY5cEUuWqKZut2o6BI5cf3oexOlCMZvQ=",
                    ClientURL = "https://clientapptwo.example.com",
                    IsActive = true
                }
            );
        }

        public DbSet<User> Users { get; set; } = null!;
        public DbSet<Role> Roles { get; set; } = null!;
        public DbSet<UserRole> UserRoles { get; set; } = null!;
        public DbSet<Client> Clients { get; set; } = null!;
        public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
    }
}
Console Application to generate Client Secret:

Please execute the following Console application to generate a 256-bit (32-byte) cryptographically secure random key and print it as a Base64 string.

using System.Security.Cryptography;
namespace ClientSecretKeyGenerator
{
    public class Program
    {
        public static void Main(string[] args)
        {
            // Generate 32 random bytes (256 bits)
            byte[] keyBytes = new byte[32];
            using (var rng = RandomNumberGenerator.Create())
            {
                rng.GetBytes(keyBytes);
            }

            // Convert to Base64 string
            string base64Key = Convert.ToBase64String(keyBytes);

            Console.WriteLine("Generated 256-bit Base64 key:");
            Console.WriteLine(base64Key);
            Console.ReadKey();
        }
    }
}
Creating Services

First, create a folder named ‘Services’ in the Project root directory, where we will store all our service classes.

Create a Singleton Client Cache Service

We can improve performance by caching the Clients table data in memory so we don’t hit the database on every token generation and validation.

IClientCacheService

Create an interface named IClientCacheService.cs within the Services folder, then copy and paste the following code.

using JWTDemo.Models;
namespace JWTDemo.Services
{
    public interface IClientCacheService
    {
        // Async method: fetch from cache or DB if missing and update cache
        Task<Client?> GetClientByClientIdAsync(string clientId);
    }
}
ClientCacheService

Create a class file named ClientCacheService.cs within the Services folder, then copy and paste the following code.

using JWTDemo.Data;
using JWTDemo.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;

namespace JWTDemo.Services
{
    public class ClientCacheService : IClientCacheService
    {
        // Prefix to uniquely identify each client entry in the cache
        private const string CacheKeyPrefix = "Client_";

        // Service provider to create scopes for scoped services like DbContext
        private readonly IServiceProvider _serviceProvider;

        // Memory cache instance for storing cached client data in-memory
        private readonly IMemoryCache _memoryCache;

        // Constructor injects required services: IServiceProvider and IMemoryCache
        public ClientCacheService(IServiceProvider serviceProvider, IMemoryCache memoryCache)
        {
            _serviceProvider = serviceProvider;
            _memoryCache = memoryCache;
        }

        // Gets a Client entity by ClientId asynchronously.
        // First attempts to get the client from in-memory cache.
        // If the client is not found in cache, fetches from the database,
        // caches the client, then returns it.
        public async Task<Client?> GetClientByClientIdAsync(string clientId)
        {
            // Construct cache key for this client using prefix and clientId
            var cacheKey = CacheKeyPrefix + clientId;

            // Attempt to retrieve the client from in-memory cache
            if (_memoryCache.TryGetValue<Client>(cacheKey, out var client))
            {
                // Cache hit - return the cached client immediately
                return client;
            }

            // Cache miss - create a new scope to get a fresh DbContext instance
            using var scope = _serviceProvider.CreateScope();
            var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

            // Query the database asynchronously for active client matching the clientId
            client = await dbContext.Clients.AsNoTracking()
                .FirstOrDefaultAsync(c => c.ClientId == clientId && c.IsActive);

            if (client != null)
            {
                // Store the retrieved client into cache with expiration policy
                // Here it expires 1 hour after being added to cache; adjust as needed
                _memoryCache.Set(cacheKey, client, new MemoryCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
                });
            }

            // Return the client entity (null if not found in DB)
            return client;
        }
    }
}
ITokenService.cs

Create an interface named ITokenService.cs within the Services folder, then copy and paste the following code. This interface defines methods for generating and validating JWT tokens and refresh tokens.

using JWTDemo.Models;
namespace JWTDemo.Services
{
    public interface ITokenService
    {
        string GenerateAccessToken(User user, IList<string> roles, out string jwtId, Client client);
        RefreshToken GenerateRefreshToken(string ipAddress, string jwtId, Client client, int userId);
    }
}
TokenService.cs

Create a class file named TokenService.cs within the Services folder, then copy and paste the following code. It implements JWT token generation, refresh token creation, and validation logic based on configured options.

using JWTDemo.Models;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;

namespace JWTDemo.Services
{
    public class TokenService : ITokenService
    {
        // IConfiguration to access appsettings.json values like issuer and token expiry times
        private readonly IConfiguration _configuration;

        // Constructor injects IConfiguration dependency
        public TokenService(IConfiguration configuration)
        {
            _configuration = configuration;
        }

        // Generates a JWT Access Token for the authenticated user.
        public string GenerateAccessToken(User user, IList<string> roles, out string jwtId, Client client)
        {
            // Initialize JWT token handler which creates and serializes tokens
            var tokenHandler = new JwtSecurityTokenHandler();

            // Decode the Base64-encoded client secret key into byte array for signing
            var keyBytes = Convert.FromBase64String(client.ClientSecret);
            var key = new SymmetricSecurityKey(keyBytes);

            // Generate a new unique identifier for the JWT token (jti claim)
            jwtId = Guid.NewGuid().ToString();

            // Read issuer and token expiration from configuration, with default fallback values
            var issuer = _configuration["JwtSettings:Issuer"] ?? "DefaultIssuer";
            var accessTokenExpirationMinutes = int.TryParse(_configuration["JwtSettings:AccessTokenExpirationMinutes"], out var val) ? val : 15;

            // Define the claims to be embedded in the JWT token
            var claims = new List<Claim>
            {
                // Subject claim represents user identifier
                new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),

                // JWT ID claim for unique token identification (used to link refresh tokens)
                new Claim(JwtRegisteredClaimNames.Jti, jwtId),

                // User email claim for identification purposes
                new Claim(JwtRegisteredClaimNames.Email, user.Email),

                // Issuer claim indicating the token issuer
                new Claim(JwtRegisteredClaimNames.Iss, issuer),

                // Audience claim specifying the client URL expected to receive the token
                new Claim(JwtRegisteredClaimNames.Aud, client.ClientURL),

                // Custom claim specifying the client id (helps identify which client requested the token)
                new Claim("client_id", client.ClientId)
            };

            // Add role claims for authorization and role-based access control
            claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));

            // Create signing credentials with the symmetric security key and HMAC SHA256 algorithm
            var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256Signature);

            // Define the JWT token descriptor containing claims, expiration, signing credentials, issuer, and audience
            var tokenDescriptor = new SecurityTokenDescriptor
            {
                Subject = new ClaimsIdentity(claims), // Claims identity constructed from claims list
                Expires = DateTime.UtcNow.AddMinutes(accessTokenExpirationMinutes), // Token expiration time
                SigningCredentials = creds, // Signing credentials using client secret key
                Issuer = issuer, // Token issuer
                Audience = client.ClientURL // Token audience (client URL)
            };

            // Create the JWT token based on the descriptor
            var token = tokenHandler.CreateToken(tokenDescriptor);

            // Serialize the JWT token to compact JWT format string
            return tokenHandler.WriteToken(token);
        }

        // Generates a Refresh Token linked to a JWT token and client.
        public RefreshToken GenerateRefreshToken(string ipAddress, string jwtId, Client client, int userId)
        {
            // Generate a secure random 64-byte array to be used as the refresh token string
            var randomBytes = new byte[64];
            using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
            rng.GetBytes(randomBytes);

            // Read refresh token expiration duration from config, default to 7 days if not set
            var refreshTokenExpirationDays = int.TryParse(_configuration["JwtSettings:RefreshTokenExpirationDays"], out var val) ? val : 7;

            // Create and return the RefreshToken entity with all required properties set
            return new RefreshToken
            {
                // Refresh token string encoded as Base64 for safe storage and transmission
                Token = Convert.ToBase64String(randomBytes),

                // Link to the JWT ID so that refresh token can be invalidated if corresponding JWT is revoked
                JwtId = jwtId,

                // Set expiration date for refresh token
                Expires = DateTime.UtcNow.AddDays(refreshTokenExpirationDays),

                // Timestamp when refresh token was created
                CreatedAt = DateTime.UtcNow,

                // Associate refresh token with the user ID
                UserId = userId,

                // Associate refresh token with the client ID
                ClientId = client.Id,

                // Initially mark the token as active (not revoked)
                IsRevoked = false,

                // No revocation date since token is active
                RevokedAt = null,

                // Store IP address from which the refresh token was created (useful for audits)
                CreatedByIp = ipAddress
            };
        }
    }
}
Creating User Service

Now, we will create the User Service to handle Registration, Login, Token Generation, and Refresh Token Storage.

IUserService.cs

Create an interface named IUserService.cs within the Services folder, then copy and paste the following code. This interface defines user-related business operations such as registration, login, token refresh, and revocation.

using JWTDemo.DTOs;

namespace JWTDemo.Services
{
    public interface IUserService
    {
        Task<bool> RegisterUserAsync(UserRegisterDTO registerDto);
        Task<AuthResponseDTO?> AuthenticateUserAsync(UserLoginDTO loginDto, string ipAddress);
        Task<AuthResponseDTO?> RefreshTokenAsync(string refreshToken, string clientId, string ipAddress);
        Task<bool> RevokeRefreshTokenAsync(string refreshToken, string ipAddress);
    }
}
UserService.cs

Create a class file named UserService.cs within the Services folder, then copy and paste the following code. It contains business logic for user management, password hashing, JWT generation, refresh token management, and client validation.

using JWTDemo.Data;
using JWTDemo.DTOs;
using JWTDemo.Models;
using Microsoft.EntityFrameworkCore;
namespace JWTDemo.Services
{
public class UserService : IUserService
{
// EF Core DbContext for database operations
private readonly ApplicationDbContext _dbContext;
// Service for generating and validating JWT tokens
private readonly ITokenService _tokenService;
// For accessing configuration values like token expiration time
private readonly IConfiguration _configuration;
// Cache service to quickly get Client info without DB calls every time
private readonly IClientCacheService _clientCacheService;
// Constructor injects dependencies via Dependency Injection
public UserService(ApplicationDbContext dbContext, ITokenService tokenService, IConfiguration configuration, IClientCacheService clientCacheService)
{
_dbContext = dbContext;
_tokenService = tokenService;
_configuration = configuration;
_clientCacheService = clientCacheService;
}
// Registers a new user with details from UserRegisterDTO
public async Task<bool> RegisterUserAsync(UserRegisterDTO registerDto)
{
// Check if a user with the same email already exists to enforce unique emails
if (await _dbContext.Users.AnyAsync(u => u.Email == registerDto.Email))
return false; // Registration fails if email is already taken
// Create new User entity, hash the password using BCrypt for security
var user = new User
{
Firstname = registerDto.Firstname,
Lastname = registerDto.Lastname,
Email = registerDto.Email,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(registerDto.Password),
IsActive = true
};
// Assign default role "User" to new users
var userRole = await _dbContext.Roles.FirstOrDefaultAsync(r => r.Name == "User");
if (userRole != null)
user.UserRoles.Add(new UserRole { RoleId = userRole.Id, User = user });
// Add the new user entity to the DbContext and save changes to the database
_dbContext.Users.Add(user);
await _dbContext.SaveChangesAsync();
return true; // Registration succeeded
}
// Authenticates user login and returns tokens if successful
public async Task<AuthResponseDTO?> AuthenticateUserAsync(UserLoginDTO loginDto, string ipAddress)
{
// Retrieve user by email with roles eagerly loaded; only active users allowed
var user = await _dbContext.Users
.Include(u => u.UserRoles).ThenInclude(ur => ur.Role)
.FirstOrDefaultAsync(u => u.Email == loginDto.Email && u.IsActive);
// Verify user exists and password matches the stored hashed password
if (user == null || !BCrypt.Net.BCrypt.Verify(loginDto.Password, user.PasswordHash))
return null; // Invalid credentials
// Extract list of role names for inclusion in JWT claims
var roles = user.UserRoles.Select(ur => ur.Role.Name).ToList();
// Retrieve client info by ClientId
var client = await _clientCacheService.GetClientByClientIdAsync(loginDto.ClientId);
if (client == null)
{
// Fail if client does not exist or is inactive
return null;
}
// Generate JWT access token with user details, roles, and client info
var accessToken = _tokenService.GenerateAccessToken(user, roles, out string jwtId, client);
// Generate refresh token linked to the generated JWT ID, client, user, and IP address
var refreshToken = _tokenService.GenerateRefreshToken(ipAddress, jwtId, client, user.Id);
// Store the refresh token in the database for later validation and refresh workflows
_dbContext.RefreshTokens.Add(refreshToken);
await _dbContext.SaveChangesAsync();
// Read access token expiration duration from config or fallback to 15 minutes
var accessTokenExpiryMinutes = int.TryParse(_configuration["JwtSettings:AccessTokenExpirationMinutes"], out var val) ? val : 15;
// Return the tokens and expiry info encapsulated in AuthResponseDTO
return new AuthResponseDTO
{
AccessToken = accessToken,
RefreshToken = refreshToken.Token,
AccessTokenExpiresAt = DateTime.UtcNow.AddMinutes(accessTokenExpiryMinutes)
};
}
// Refreshes an expired access token using a valid refresh token and client ID
public async Task<AuthResponseDTO?> RefreshTokenAsync(string refreshToken, string clientId, string ipAddress)
{
// Retrieve client info by clientId for validation
var client = await _clientCacheService.GetClientByClientIdAsync(clientId);
if (client == null)
{
// Client invalid or inactive; reject refresh
return null;
}
// Look up the refresh token in database, including related user and roles for new token generation
var existingToken = await _dbContext.RefreshTokens
.Include(rt => rt.User)
.ThenInclude(u => u.UserRoles)
.ThenInclude(ur => ur.Role)
.FirstOrDefaultAsync(rt => rt.Token == refreshToken && rt.ClientId == client.Id);
// Validate refresh token existence, revocation status, and expiration
if (existingToken == null || existingToken.IsRevoked || existingToken.Expires <= DateTime.UtcNow)
return null; // Invalid refresh token
// Revoke old refresh token immediately to prevent reuse
existingToken.IsRevoked = true;
existingToken.RevokedAt = DateTime.UtcNow;
var user = existingToken.User;
var roles = user.UserRoles.Select(ur => ur.Role.Name).ToList();
// Generate a new access token with fresh JWT ID and client info
var accessToken = _tokenService.GenerateAccessToken(user, roles, out string newJwtId, client);
// Generate a new refresh token linked to the new JWT ID
var newRefreshToken = _tokenService.GenerateRefreshToken(ipAddress, newJwtId, client, user.Id);
// Store the new refresh token in the database
_dbContext.RefreshTokens.Add(newRefreshToken);
await _dbContext.SaveChangesAsync();
// Read access token expiration duration from config or default to 15 minutes
var accessTokenExpiryMinutes = int.TryParse(_configuration["JwtSettings:AccessTokenExpirationMinutes"], out var val) ? val : 15;
// Return the new tokens and expiry info
return new AuthResponseDTO
{
AccessToken = accessToken,
RefreshToken = newRefreshToken.Token,
AccessTokenExpiresAt = DateTime.UtcNow.AddMinutes(accessTokenExpiryMinutes)
};
}
// Revokes an existing refresh token to prevent further use
public async Task<bool> RevokeRefreshTokenAsync(string refreshToken, string ipAddress)
{
// Look up the refresh token in the database
var existingToken = await _dbContext.RefreshTokens.FirstOrDefaultAsync(rt => rt.Token == refreshToken);
// Return false if token not found or already revoked
if (existingToken == null || existingToken.IsRevoked)
return false;
// Mark token as revoked and record revocation time
existingToken.IsRevoked = true;
existingToken.RevokedAt = DateTime.UtcNow;
// Persist changes to database
await _dbContext.SaveChangesAsync();
return true; // Indicate successful revocation
}
}
}
appsettings.json (Connection string and JWT settings)

Stores configuration values, including database connection strings and JWT options. Please modify the appsettings.json file as follows:

{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"EFCoreDBConnection": "Server=LAPTOP-6P5NK25R\\SQLSERVER2022DEV;Database=JWTAuthDb;Trusted_Connection=True;TrustServerCertificate=True;"
},
"JwtSettings": {
"AccessTokenExpirationMinutes": 15,
"RefreshTokenExpirationDays": 7,
"Issuer": "JWTDemoApi"
}
}
Program.cs – Configure Services and Middleware

Please modify the Program class as follows. This class is the application entry point, configuring DI services, JWT authentication middleware, EF Core, and routing.

using JWTDemo.Data;
using JWTDemo.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
namespace JWTDemo
{
public class Program
{
public static void Main(string[] args)
{
// Create the WebApplication builder to configure services and middleware
var builder = WebApplication.CreateBuilder(args);
// Register MVC Controllers and configure JSON serialization options
// Here, disabling camel case so property names remain as declared in C# classes
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = null;
});
// Register Swagger services to generate API documentation
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Register EF Core DbContext with SQL Server using connection string from configuration
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("EFCoreDBConnection")));
// Register in-memory caching services for application-wide cache storage
builder.Services.AddMemoryCache();
// Register the ClientCacheService as singleton to maintain client info cache across requests
builder.Services.AddSingleton<IClientCacheService, ClientCacheService>();
// Register application services with Scoped lifetime (per HTTP request)
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IUserService, UserService>();
// Declare a Lazy<IClientCacheService> variable to be initialized later 
// This allows deferred resolution of the client cache service after the app is built
Lazy<IClientCacheService>? clientCacheInstance = null;
// Configure JWT Bearer Authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// Setup token validation parameters
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true, // Validate that token issuer matches expected issuer
ValidIssuer = builder.Configuration["JwtSettings:Issuer"], // Expected issuer value
ValidateAudience = false, // Audience validated manually later
ValidateIssuerSigningKey = true, // Validate the token's signing key
ValidateLifetime = true, // Validate token expiration and not-before times
// Dynamically obtains the signing key based on the client_id claim,
// fetching the corresponding client’s secret key from cache.
IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>
{
// Parse the incoming JWT token to extract claims
var jwtToken = new JwtSecurityToken(token);
// Extract client_id claim to identify which client signed this token
var clientId = jwtToken.Claims.FirstOrDefault(c => c.Type == "client_id")?.Value;
// If clientId or client cache is not available, return empty keys => fail validation
if (string.IsNullOrEmpty(clientId) || clientCacheInstance == null)
return Enumerable.Empty<SecurityKey>();
// Retrieve the client info synchronously from cache
var client = clientCacheInstance.Value.GetClientByClientIdAsync(clientId).Result;
if (client == null)
return Enumerable.Empty<SecurityKey>();
// Convert the client's stored Base64 secret into a byte array for key
var keyBytes = Convert.FromBase64String(client.ClientSecret);
// Create the symmetric security key from byte array for signature validation
return new[] { new SymmetricSecurityKey(keyBytes) };
}
};
// Additional asynchronous validation after the token is validated,
// confirming the client exists and audience matches the stored client URL.
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
// Extract client_id claim from the validated token
var clientId = context.Principal?.FindFirst("client_id")?.Value;
if (string.IsNullOrEmpty(clientId))
{
// Fail if claim is missing
context.Fail("ClientId claim missing.");
return;
}
if(clientCacheInstance == null)
{
context.Fail("Client Cache Instance is null");
return;
}
// Asynchronously get client info from cache or database
var client = await clientCacheInstance.Value.GetClientByClientIdAsync(clientId);
if (client == null)
{
// Fail if client not found
context.Fail("Invalid client.");
return;
}
// Extract audience claim from token and compare to client URL stored in DB/cache
var audClaim = context.Principal?.FindFirst(JwtRegisteredClaimNames.Aud)?.Value;
if (audClaim != client.ClientURL)
{
// Fail if audience doesn't match
context.Fail("Invalid audience.");
return;
}
}
};
});
// Build the application pipeline; after this, the services collection is read-only
var app = builder.Build();
// Initialize the lazy client cache instance now that the DI container is built and available
clientCacheInstance = new Lazy<IClientCacheService>(() =>
app.Services.GetRequiredService<IClientCacheService>());
// Enable middleware to generate Swagger UI documentation during development
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// Enforce HTTPS redirection middleware for security
app.UseHttpsRedirection();
// Enable authentication and authorization middleware for API endpoints
app.UseAuthentication();
app.UseAuthorization();
// Map incoming HTTP requests to controller action methods
app.MapControllers();
// Run the application, blocking the thread to listen for requests
app.Run();
}
}
}
Creating Authentication Controller:

Create an API Empty controller named AuthController within the Controllers folder, then copy and paste the following code into it. It defines API endpoints for registration, login, and token refreshing. Handles input validation and returns appropriate responses.

using JWTDemo.DTOs;
using JWTDemo.Services;
using Microsoft.AspNetCore.Mvc;
namespace JWTDemo.Controllers
{
[ApiController] 
[Route("api/[controller]")] 
public class AuthController : ControllerBase
{
private readonly IUserService _userService;
// Constructor receives IUserService via Dependency Injection
public AuthController(IUserService userService)
{
_userService = userService;
}
// POST api/auth/register
// Endpoint for user registration
[HttpPost("register")]
public async Task<IActionResult> Register(UserRegisterDTO registerDto)
{
// Validate input model (e.g., required fields, formats)
if (!ModelState.IsValid)
return BadRequest(ModelState); // Return 400 with validation errors
// Call UserService to register user
var success = await _userService.RegisterUserAsync(registerDto);
// If registration fails (email exists), return 400 with custom message
if (!success)
return BadRequest(new { message = "Email already exists." });
// Successful registration: return 200 OK with success message
return Ok(new { message = "User registered successfully." });
}
// POST api/auth/login
// Endpoint for user login and JWT token generation
[HttpPost("login")]
public async Task<IActionResult> Login(UserLoginDTO loginDto)
{
// Validate input model (email, password, clientId)
if (!ModelState.IsValid)
return BadRequest(ModelState); // Return 400 with validation errors
// Get client IP address for logging and refresh token generation
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
// Call UserService to authenticate user and get JWT + refresh tokens
var authResponse = await _userService.AuthenticateUserAsync(loginDto, ipAddress);
// If authentication fails (invalid credentials or client), return 401 Unauthorized
if (authResponse == null)
return Unauthorized(new { message = "Invalid credentials or client." });
// Successful login: return 200 OK with tokens and expiry info
return Ok(authResponse);
}
// POST api/auth/refresh-token
// Endpoint to obtain a new access token using a refresh token
[HttpPost("refresh-token")]
public async Task<IActionResult> RefreshToken(RefreshTokenRequestDTO refreshRequest)
{
// Validate input model (refreshToken and clientId required)
if (!ModelState.IsValid)
return BadRequest(ModelState); // Return 400 with validation errors
// Get client IP address (optional for logging/auditing)
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
// Call UserService to validate refresh token and issue new access & refresh tokens
var authResponse = await _userService.RefreshTokenAsync(refreshRequest.RefreshToken, refreshRequest.ClientId, ipAddress);
// If refresh token or client is invalid, return 401 Unauthorized
if (authResponse == null)
return Unauthorized(new { message = "Invalid refresh token or client." });
// Successful token refresh: return 200 OK with new tokens and expiry info
return Ok(authResponse);
}
}
}
ProtectedController.cs

Create an API Empty controller named ProtectedController within the Controllers folder, then copy and paste the following code into it. It provides example endpoints secured by JWT authentication and role-based authorization, demonstrating how to restrict access.

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace JWTDemo.Controllers
{
[ApiController] 
[Route("api/[controller]")] 
[Authorize] // Requires the caller to be authenticated with a valid JWT token for all actions
public class ProtectedController : ControllerBase
{
// GET api/protected/userdata
// Accessible by any authenticated user
[HttpGet("userdata")]
public IActionResult GetUserData()
{
// Extract the user's email from JWT claims; if missing, default to "unknown"
var userEmail = User.FindFirstValue(ClaimTypes.Email) ?? "unknown";
// Extract the user's ID from JWT claims (typically stored in NameIdentifier); default to "unknown"
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown";
// Return a friendly message including user's email and ID
return Ok(new { message = $"Hello, {userEmail}! Your User ID is {userId}." });
}
// GET api/protected/adminonly
// This endpoint requires the user to have the "Admin" role claim
[HttpGet("adminonly")]
[Authorize(Roles = "Admin")] // Restricts access to users who have the "Admin" role claim
public IActionResult AdminOnly()
{
// Return a success message for authorized admin users
return Ok(new { message = "Welcome, Admin! You have access to this resource." });
}
}
}
Database Migration

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

JSON Web Token (JWT) Based Token Authentication in ASP.NET Core Web API Application with Examples

With this, our Database with the required database tables should be created under the JWTAuthDb database as shown in the image below:

JSON Web Token (JWT) Based Token Authentication in ASP.NET Core Web API Application with Examples

Endpoints and Sample Requests

Please replace the port with your app’s actual HTTPS port.

Register User

Method: POST
URL: https://localhost:5001/api/Auth/register
Body (raw JSON):

{
"Firstname": "Pranaya",
"Lastname": "Rout",
"Email": "pranaya.rout@example.com!",
"Password": "Password123!"
}
Login User

Method: POST
URL: https://localhost:5001/api/Auth/login
Body (raw JSON):

{
"Email": "pranaya.rout@example.com!",
"Password": "Password123!",
"ClientId": "client-app-one"
}
Refresh Token

Method: POST
URL: https://localhost:5001/api/Auth/refresh-token
Body (raw JSON):

{
"RefreshToken": "uZ3...",      // Use RefreshToken value from login response
"ClientId": "client-app-one"
}

Expected Response:
New access and refresh tokens similar to the login response.

Access Protected Endpoint

Method: GET
URL: https://localhost:5001/api/Protected/userdata
Headers:
Authorization: Bearer <accessToken> (use the accessToken from login or refresh response)

Access Admin-Only Endpoint

Method: GET
URL: https://localhost:5001/api/Protected/adminonly
Headers:
Authorization: Bearer <accessToken> (token must have Admin role)

.NET Console Application to Consume Web API with JWT and Refresh Token

First, create a new .NET Console Application named JWTClientDemo. Create a folder named DTOs in the project root directory. Then, create the following DTOs to match your API’s contract.

LoginRequest

Create a class file named LoginRequest.cs in the DTOs folder of the JWTClientDemo project, then copy and paste the following code.

namespace JWTClientDemo.DTOs
{
public class LoginRequest
{
public string Email { get; set; } = null!;
public string Password { get; set; } = null!;
public string ClientId { get; set; } = null!;
}
}
AuthResponse

Create a class file named AuthResponse.cs in the DTOs folder of the JWTClientDemo project, then copy and paste the following code.

namespace JWTClientDemo.DTOs
{
public class AuthResponse
{
public string AccessToken { get; set; } = null!;
public string RefreshToken { get; set; } = null!;
public DateTime AccessTokenExpiresAt { get; set; }
}
}
RefreshTokenRequest

Create a class file named RefreshTokenRequest.cs in the DTOs folder of the JWTClientDemo project, then copy and paste the following code.

namespace JWTClientDemo.DTOs
{
public class RefreshTokenRequest
{
public string RefreshToken { get; set; } = null!;
public string ClientId { get; set; } = null!;
}
}
Program class:

Next, modify the Program class as follows.

using JWTClientDemo.DTOs;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace JWTClientDemo
{
internal class Program
{
// Change this base URL as per your API setup
private static readonly string baseUrl = "https://localhost:7270";
private static readonly string clientId = "client-app-one";
private static string accessToken = string.Empty;
private static string refreshToken = string.Empty;
static async Task Main(string[] args)
{
// 1. Login to get JWT + Refresh Token
Console.WriteLine("Logging in...");
var auth = await LoginAsync("pranaya.rout@example.com", "Password123!", clientId);
if (auth == null)
{
Console.WriteLine("Login failed!");
return;
}
accessToken = auth.AccessToken;
refreshToken = auth.RefreshToken;
Console.WriteLine("Login successful!");
Console.WriteLine($"Access Token: {accessToken.Substring(0, 20)}...");
Console.WriteLine($"Refresh Token: {refreshToken.Substring(0, 20)}...");
// 2. Call protected endpoint with JWT
await CallProtectedApiAsync();
// 3. Simulate expiry: manually clear JWT to test refresh flow (for demo)
Console.WriteLine("\nSimulating token expiry...");
accessToken = "InvalidOrExpiredToken";
// 4. Try protected endpoint again, expect 401
await CallProtectedApiAsync();
// 5. Use Refresh Token to get new JWT
Console.WriteLine("\nRefreshing Access Token...");
var newAuth = await RefreshTokenAsync(refreshToken, clientId);
if (newAuth == null)
{
Console.WriteLine("Refresh token failed!");
return;
}
accessToken = newAuth.AccessToken;
refreshToken = newAuth.RefreshToken;
Console.WriteLine("Access Token refreshed!");
// 6. Try protected endpoint again with new token
await CallProtectedApiAsync();
Console.WriteLine("\nDemo completed");
Console.ReadKey();
}
static async Task<AuthResponse?> LoginAsync(string email, string password, string clientId)
{
using (var client = new HttpClient())
{
var loginReq = new
{
Email = email,
Password = password,
ClientId = clientId
};
var content = new StringContent(JsonSerializer.Serialize(loginReq), Encoding.UTF8, "application/json");
var resp = await client.PostAsync($"{baseUrl}/api/Auth/login", content);
if (!resp.IsSuccessStatusCode)
{
Console.WriteLine($"Login failed: {resp.StatusCode}");
return null;
}
var body = await resp.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<AuthResponse>(body);
}
}
static async Task CallProtectedApiAsync()
{
using (var client = new HttpClient())
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var resp = await client.GetAsync($"{baseUrl}/api/Protected/userdata");
if (resp.IsSuccessStatusCode)
{
var body = await resp.Content.ReadAsStringAsync();
Console.WriteLine("\n[Protected API Response]:");
Console.WriteLine(body);
}
else
{
Console.WriteLine($"\n[Protected API] Failed: {resp.StatusCode}");
var body = await resp.Content.ReadAsStringAsync();
Console.WriteLine(body);
}
}
}
static async Task<AuthResponse?> RefreshTokenAsync(string refreshToken, string clientId)
{
using (var client = new HttpClient())
{
var req = new
{
RefreshToken = refreshToken,
ClientId = clientId
};
var content = new StringContent(JsonSerializer.Serialize(req), Encoding.UTF8, "application/json");
var resp = await client.PostAsync($"{baseUrl}/api/Auth/refresh-token", content);
if (!resp.IsSuccessStatusCode)
{
Console.WriteLine($"Refresh token failed: {resp.StatusCode}");
return null;
}
var body = await resp.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<AuthResponse>(body);
}
}
}
}
Output:

.NET Console Application to Consume Web API with JWT and Refresh Token

JWT Authentication offers a secure, scalable, and stateless approach to protecting ASP.NET Core Web APIs from unauthorized access. By implementing access and refresh tokens, we can ensure that only authenticated users and trusted client applications interact with our API endpoints. Tokens enable users to stay logged in without having to send their passwords each time, and they help protect sensitive information.

In the next article, I will discuss implementing CORS in an ASP.NET Core Web API Application. In this article, I explain JWT Authentication in an ASP.NET Core Web API Application with an Example. I hope you enjoy this article on ASP.NET Core Web API JWT Token-Based Authentication.

Leave a Reply

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