Back to: ASP.NET Core Web API Tutorials
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:
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:
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:
Once you visit the above page, you will see the following.
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:
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.
With this, our Database with the required database tables should be created under the JWTAuthDb database as shown in the image below:
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:
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.