Create a new web api project
Run these commands in a sequence to create a new project.
dotnet new sln -o JwtDotnet9
cd JwtDotnet9
dotnet sln add JwtDotnet9/JwtDotnet9.csproj
Open the project in vs code.
code .
=> Source Code
=> Securing The .NET 9 App: Signup, Login, JWT, Refresh Tokens, and Role Based Access with PostgreSQL
Install the required nuget packages
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
Jwt configuration in appsettings
Open appsettings.json
and add these lines
"JWT": {
"ValidAudience": "http://localhost:5163",
"ValidIssuer": "http://localhost:5163",
"Secret": "your-32-characters-long-super-strong-jwt-secret-key"
}
Let’s understand, what are these:
- ValidAudience: It defines who is going to consume this token. In our case, the application running on the
http://localhost:5163
can consume this token. Make sure to change it to according to your audience. - ValidIssuer: Issuer of the token.
- JwtSecret : Secret key for the token. We are going to store it in appsettings for the sake of simplicity. But this value should be rotated, that is why, for production we need to put it in a place like
azure key vault
.
JWT configuration in Program.cs
Open the Program.cs
and add these lines.
// Authentication
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.SaveToken = true;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidAudience = builder.Configuration["JWT:ValidAudience"],
ValidIssuer = builder.Configuration["JWT:ValidIssuer"],
ClockSkew = TimeSpan.Zero,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["JWT:secret"]))
};
});
If you don’t set `ClockSkew = TimeSpan.Zero’, then you can not set JWT expiry time to less than 5 minutes. For testing stuff, you need short expiry (around 1 or 2 min).
Make sure to include these lines also
app.UseAuthentication();
app.UseAuthorization();
Token Service
// Services/TokenService.cs
using System.Security.Claims;
namespace JwtDotnet9.Services;
public interface ITokenService
{
string GenerateAccessToken(IEnumerable<Claim> claims);
}
public class TokenService : ITokenService
{
public string GenerateAccessToken(IEnumerable<Claim> claims)
{
throw new NotImplementedException();
}
}}
For the sake of simplicity, I have created the interface and a class in the same file, it is not a recommended practice, make sure to create them separately.
Let’s implement the TokenService
class and inject the IConfiguration
to it. Through IConfiguration
we can get things from appsettings.json
.
public class TokenService : ITokenService
{
private readonly IConfiguration _configuration;
public TokenService(IConfiguration configuration)
{
_configuration = configuration;
}
public string GenerateAccessToken(IEnumerable<Claim> claims)
{
throw new NotImplementedException();
}
}
Let’s complete the GenerateAccessToken()
method.
public string GenerateAccessToken(IEnumerable<Claim> claims)
{
var tokenHandler = new JwtSecurityTokenHandler();
// Create a symmetric security key using the secret key from the configuration.
var authSigningKey = new SymmetricSecurityKey
(Encoding.UTF8.GetBytes(_configuration["JWT:Secret"]));
var tokenDescriptor = new SecurityTokenDescriptor
{
Issuer = _configuration["JWT:ValidIssuer"],
Audience = _configuration["JWT:ValidAudience"],
Subject = new ClaimsIdentity(claims),
Expires = DateTime.Now.AddMinutes(15),
SigningCredentials = new SigningCredentials
(authSigningKey, SecurityAlgorithms.HmacSha256)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
Register the token service
// Program.cs
builder.Services.AddTransient<ITokenService, TokenService>();
Create a login model
// Models/LoginModel.cs
using System.ComponentModel.DataAnnotations;
namespace JwtDotnet9.Models;
public class LoginModel
{
[Required]
public string Username { get; set; } = string.Empty;
[Required]
public string Password { get; set; } = string.Empty;
}
Create UserModel
// Models/User.cs
namespace JwtDotnet9.Models;
public class User
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public List<string> Roles { get; set; } = [];
}
UserService
Create a user service to manipulate the user related operation. I have created a list of users, because I am not going to use any database in this project. You know why (for the sake of simplicity). For production, please use Identity
or any similar authentication mechanism to authenticate and store the user.
// Services/UserService.cs
using JwtDotnet9.Models;
namespace JwtDotnet9.Services;
public interface IUserService
{
User? GetUser(string username, string password);
}
public class UserService : IUserService
{
private List<User> _users = new()
{
new()
{
Username = "admin",
Password = "admin123",
Roles = ["Admin"]
},
new()
{
Username = "user",
Password = "user123",
Roles = ["User"]
}
};
public User? GetUser(string username, string password)
{
return _users.FirstOrDefault(u => u.Username == username && u.Password == password);
}
}
Make sure to register it in the Program.cs
builder.Services.AddTransient<IUserService, UserService>();
Create AccountController
Create AccountController
and inject ITokenService
and IUserService
to it.
// Controllers/AccountController.cs
[ApiController]
[Route("/accounts")]
public class AccountController : ControllerBase
{
private readonly ITokenService _tokenService;
private readonly IUserService _userService;
public AccountController(ITokenService tokenService, IUserService userService)
{
_tokenService = tokenService;
_userService = userService;
}
}
Let’s implement the Login()
method
[HttpPost("login")]
public IActionResult Login(LoginModel model)
{
try
{
var user = _userService.GetUser(model.Username, model.Password);
if (user == null)
{
return Unauthorized("Invalid username or password");
}
// creating the necessary claims
List<Claim> claims = [
new (ClaimTypes.Name, user.Username), // claim to store name
new (JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
// unique identifier for jwt
];
// adding roles to claims
foreach (var role in user.Roles)
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
// generating access token
var token = _tokenService.GenerateAccessToken(claims);
return Ok(token);
}
catch (Exception ex)
{
return Unauthorized();
}
}
What is Claim?
A claim
represents a piece of information about a user in the form of key-value pair. Claims are present in the tokens. Example:
// Built-in Claim Types
new Claim(ClaimTypes.Name, "johndoe") // Username
new Claim(ClaimTypes.Email, "john@example.com") // Email address
new Claim(ClaimTypes.Role, "Admin") // User role
// Cusom claim
new Claim("Department", "IT") // Custom claim
Testing the endpoint
I am using Rest Client vs code extension by Huachao Mao. If you are using Visual Studio 2022, it is already integrated. However, you can use any client of your choice (eg. Postman, Insomnia).
In your application, you have a file with extension .http
. In my project it is named as JwtDotnet9.http
. Open that file, which should look like this
@JwtDotnet9_HostAddress = http://localhost:5163
GET {{JwtDotnet9_HostAddress}}/weatherforecast/
Accept: application/json
###
Login endpoint test
Add these line below the symbol ###
.
POST {{JwtDotnet9_HostAddress}}/accounts/login
Content-Type: application/json
{
"username":"user",
"password":"user123"
}
Above the line POST {{JwtDotnet9_HostAddress}}/accounts/login
, you must be seeing a link Send Request
, click on it and you will get a response:
HTTP/1.1 200 OK
Connection: close
Content-Type: text/plain; charset=utf-8
Date: Sun, 18 May 2025 08:20:10 GMT
Server: Kestrel
Transfer-Encoding: chunked
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6InVzZXIiLCJqdGkiOiIzZTJhZDRmZC04OGY3LTQwMjUtYTkzMi0zN2YxYzkyMTgwYzIiLCJyb2xlIjoiVXNlciIsIm5iZiI6MTc0NzU1NjQxMSwiZXhwIjoxNzQ3NTU3MzExLCJpYXQiOjE3NDc1NTY0MTEsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTE2MyIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTE2MyJ9.DnjVf-vh-xnMlooRofIs7iiLrgAmAz9_ZJGUcIeBDDk
You can easily decode the JWT using a website named jwt.io
. Let’s see what is inside our JWT :
{
"unique_name": "user",
"jti": "3e2ad4fd-88f7-4025-a932-37f1c92180c2",
"role": "User",
"nbf": 1747556411,
"exp": 1747557311,
"iat": 1747556411,
"iss": "http://localhost:5163",
"aud": "http://localhost:5163"
}
Authenticating the endpoints
We can authentication our endpoints in two way:
1. At individual method level
You just need to put Authorize
attribute over the method.
[Authorize]
public IEnumerable<WeatherForecast> Get()
{
}
2. At controller level
Put Authorize
attribute above controller and all the methods of the Controller will be authorized.
[Authorize]
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
}
Allow anonymous
If you put [Authorize]
attribute at the controller level, then all the methods of the controller are going to be authorized. If you want to bypass the security for some controller methods then you need to add AllowAnonymous
attribute above that method.
[AllowAnonymous]
[HttpGet("/some-method")]
public IActionResult SomeMethod()
{
return Ok();
}
Let’s test the authenticated resource
Request:
GET {{JwtDotnet9_HostAddress}}/weatherforecast/
Accept: application/json
Response:
HTTP/1.1 401 Unauthorized
Content-Length: 0
Connection: close
Date: Sun, 18 May 2025 10:22:33 GMT
Server: Kestrel
WWW-Authenticate: Bearer
How to access the authenticated resource
To access the authenticated resource, first hit the login
endpoint and copy the JWT
from the response.
You need to pass JWT in the Authorization
header. Make sure to prepend Bearer
in the JWT token.
eg. Authorization : Bearer your_jwt
GET {{JwtDotnet9_HostAddress}}/weatherforecast/
Accept: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6InVzZXIiLCJqdGkiOiI4ZmUzNDhiYi02MjI2LTRjNGItYWFlOC0wNzIxNzYzZDdlMDgiLCJyb2xlIjoiVXNlciIsIm5iZiI6MTc0NzU2NDQzOSwiZXhwIjoxNzQ3NTY1MzM5LCJpYXQiOjE3NDc1NjQ0MzksImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTE2MyIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTE2MyJ9.ybr5EkOmXSAGWRgCvW3ZhTzJJ_7WRAzS_YXR7gFuaLM
Role based authorization
What if you want to allow access to specific ‘Role’ (eg. Admin). Then you just need to add Roles
value in the Authorize
attribute.
eg. [Authorize(Roles = "Admin")]
For multiple roles: [Authorize(Roles = "Admin,Accounts,SomeOtherRole")]
[Authorize(Roles = "Admin")]
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
}
Now, if you try to access the /weatherforecast
endpoint with user’s JWT
. Then you will get the 403 forbidden
as a response. Which indicates that you are an authenticated user, but you have no access rights to access this resource.
HTTP/1.1 403 Forbidden
Content-Length: 0
Connection: close
Date: Sun, 18 May 2025 10:48:45 GMT
Server: Kestrel
To access this resource, you have to login with admin
credentials and then use that JWT
to access the protected resources.