JWT authentication is commonly used to protect ASP.NET Core Web APIs. Instead of maintaining a traditional server-side login session, the API can issue a signed JSON Web Token after a user successfully authenticates. The client then sends that token with requests to protected API endpoints.
In this tutorial, we'll build a simple JWT authentication implementation
from scratch using ASP.NET Core. You'll create a login endpoint, generate
a JWT, configure JWT Bearer authentication, protect API endpoints with
[Authorize], and add role-based authorization.
- ASP.NET Core Web API
- JWT Bearer authentication
- Login endpoint
- JWT token generation
- Protected API endpoint
- Role-based authorization
- Token testing with Postman
What Is JWT Authentication?
JWT stands for JSON Web Token. A JWT is a compact token that can carry claims about a user or another subject.
A typical authentication flow looks like this:
- The user sends credentials to the login endpoint.
- The server validates those credentials.
- The server generates and signs a JWT.
- The API returns the token to the client.
- The client sends the token with subsequent API requests.
- The API validates the token before allowing access to protected resources.
For an HTTP API, the token is commonly sent using the Authorization header:
Authorization: Bearer YOUR_ACCESS_TOKEN
Understanding the Structure of a JWT
A JWT commonly consists of three Base64URL-encoded sections separated by periods:
header.payload.signature
Header
The header identifies information such as the token type and signing algorithm.
Payload
The payload contains claims. Claims can describe the authenticated subject, roles, issuer, audience, expiration time, and other information.
Signature
The signature allows the receiving application to verify that a signed token has not been modified after it was issued.
A signed JWT is not automatically encrypted. Do not place passwords, secret keys, or other sensitive information in the JWT payload.
Prerequisites
For this tutorial you should have:
- A supported .NET SDK installed
- Basic C# knowledge
- A code editor such as Visual Studio or Visual Studio Code
- Postman or another HTTP client for testing
You can check your installed .NET SDK version with:
dotnet --version
Step 1: Create an ASP.NET Core Web API
Open a terminal and create a new Web API project:
dotnet new webapi -n JwtAuthDemo
cd JwtAuthDemo
You can then run the project with:
dotnet run
Make sure the application starts successfully before adding authentication.
Step 2: Install the JWT Bearer Authentication Package
From the project directory, install the JWT Bearer authentication package:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
Use a package version compatible with the target framework of your application.
Step 3: Add JWT Configuration
Open appsettings.json and add a JWT configuration section.
{
"Jwt": {
"Key": "DEVELOPMENT_ONLY_CHANGE_THIS_SECRET_KEY",
"Issuer": "JwtAuthDemo",
"Audience": "JwtAuthDemoClient",
"ExpireMinutes": 15
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Step 4: Create a Login Request Model
Create a folder named Models and add
LoginRequest.cs.
namespace JwtAuthDemo.Models;
public class LoginRequest
{
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
This model represents the credentials submitted to our login endpoint.
Step 5: Create a JWT Token Service
Create a folder named Services and add
TokenService.cs.
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace JwtAuthDemo.Services;
public class TokenService
{
private readonly IConfiguration _configuration;
public TokenService(IConfiguration configuration)
{
_configuration = configuration;
}
public string CreateToken(string userId, string email, string role)
{
var jwtSettings = _configuration.GetSection("Jwt");
var keyValue = jwtSettings["Key"]
?? throw new InvalidOperationException("JWT Key is missing.");
var issuer = jwtSettings["Issuer"]
?? throw new InvalidOperationException("JWT Issuer is missing.");
var audience = jwtSettings["Audience"]
?? throw new InvalidOperationException("JWT Audience is missing.");
var expireMinutes = int.TryParse(
jwtSettings["ExpireMinutes"],
out var minutes)
? minutes
: 15;
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, userId),
new(JwtRegisteredClaimNames.Email, email),
new(ClaimTypes.NameIdentifier, userId),
new(ClaimTypes.Email, email),
new(ClaimTypes.Role, role),
new(
JwtRegisteredClaimNames.Jti,
Guid.NewGuid().ToString())
};
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(keyValue));
var credentials = new SigningCredentials(
key,
SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: issuer,
audience: audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(expireMinutes),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
The service creates claims for the user, signs the token and sets an expiration time.
Step 6: Configure JWT Authentication in Program.cs
Now configure ASP.NET Core to validate incoming JWTs.
Replace or update your Program.cs with the following configuration:
using System.Text;
using JwtAuthDemo.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddAuthorization();
builder.Services.AddScoped<TokenService>();
var jwtSettings = builder.Configuration.GetSection("Jwt");
var jwtKey = jwtSettings["Key"]
?? throw new InvalidOperationException("JWT Key is missing.");
var jwtIssuer = jwtSettings["Issuer"]
?? throw new InvalidOperationException("JWT Issuer is missing.");
var jwtAudience = jwtSettings["Audience"]
?? throw new InvalidOperationException("JWT Audience is missing.");
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters =
new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey =
new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtKey)),
ClockSkew = TimeSpan.Zero
};
});
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Two lines are particularly important:
app.UseAuthentication();
app.UseAuthorization();
Authentication establishes the identity represented by a valid token. Authorization then determines whether that identity is permitted to access a resource.
Step 7: Create the Login Endpoint
Create a Controllers folder if your project doesn't already
contain one, then create AuthController.cs.
using JwtAuthDemo.Models;
using JwtAuthDemo.Services;
using Microsoft.AspNetCore.Mvc;
namespace JwtAuthDemo.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly TokenService _tokenService;
public AuthController(TokenService tokenService)
{
_tokenService = tokenService;
}
[HttpPost("login")]
public IActionResult Login(LoginRequest request)
{
// Demo only.
// Replace this with your real user store,
// ASP.NET Core Identity or another
// authentication system.
if (request.Email != "admin@example.com" ||
request.Password != "DemoPassword123!")
{
return Unauthorized(new
{
message = "Invalid email or password."
});
}
var token = _tokenService.CreateToken(
userId: "1",
email: request.Email,
role: "Admin");
return Ok(new
{
accessToken = token
});
}
}
Step 8: Create a Protected API
Now let's create an endpoint that can only be accessed with a valid JWT.
Create SecureController.cs:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace JwtAuthDemo.Controllers;
[ApiController]
[Route("api/[controller]")]
public class SecureController : ControllerBase
{
[Authorize]
[HttpGet]
public IActionResult GetSecureData()
{
return Ok(new
{
message = "You successfully accessed a protected API."
});
}
}
The [Authorize] attribute tells ASP.NET Core that the endpoint
requires an authenticated user.
Step 9: Test the Login API
Run the application:
dotnet run
Using Postman or another HTTP client, send a POST request to:
https://localhost:YOUR_PORT/api/auth/login
Use a JSON request body:
{
"email": "admin@example.com",
"password": "DemoPassword123!"
}
If the credentials are valid, the API returns a response similar to:
{
"accessToken": "eyJhbGciOi..."
}
Copy the value of accessToken.
Step 10: Call the Protected Endpoint
Send a GET request to:
https://localhost:YOUR_PORT/api/secure
Add this request header:
Authorization: Bearer YOUR_ACCESS_TOKEN
With a valid token, the endpoint should return:
{
"message": "You successfully accessed a protected API."
}
If the token is missing or invalid, the API should respond with
401 Unauthorized.
Adding Role-Based Authorization
Our token contains an ASP.NET Core role claim:
new(ClaimTypes.Role, role)
That means we can restrict an endpoint to a particular role.
[Authorize(Roles = "Admin")]
[HttpGet("admin")]
public IActionResult AdminOnly()
{
return Ok(new
{
message = "Only an Admin can access this endpoint."
});
}
A successfully authenticated user who does not satisfy the required
authorization policy will normally receive 403 Forbidden.
Common JWT Claims
| Claim | Purpose |
|---|---|
sub |
Identifies the subject of the token |
exp |
Token expiration time |
iss |
Identifies the token issuer |
aud |
Identifies the intended audience |
jti |
Unique identifier for a token |
JWT Authentication Best Practices
The example above is intentionally small so that the authentication flow is easy to understand. A production implementation requires additional security considerations.
- Always use HTTPS. Access tokens should not be transmitted over an unencrypted HTTP connection.
- Protect signing keys. Do not store production secrets directly in source code or commit them to Git.
- Use sufficiently strong keys. Generate cryptographically strong secrets appropriate for your signing algorithm.
- Keep access tokens reasonably short-lived. The appropriate lifetime depends on the application's security and usability requirements.
- Validate issuer, audience, signature and lifetime. Don't disable validation simply to make an authentication problem disappear.
- Don't put secrets in JWT payloads. A signed token does not mean that its payload is confidential.
- Use a real user-management system. Production applications should securely manage users, credentials and password hashing rather than using the demonstration credentials shown in this tutorial.
- Design refresh-token handling carefully. If your application uses refresh tokens, treat them as sensitive credentials and plan for rotation and revocation.
What About Refresh Tokens?
Short-lived access tokens create an important usability question: what happens when the access token expires?
One common approach is to use a refresh token. The refresh token can be presented to a dedicated endpoint to obtain a new access token without asking the user to enter credentials every time an access token expires.
Refresh tokens should not simply be treated as long-lived access tokens. They require secure storage and a strategy for expiration, rotation and revocation.
For a beginner tutorial, it is better to first understand the access-token flow shown above before adding a refresh-token implementation.
Common JWT Authentication Problems
401 Unauthorized
A 401 usually means the API could not establish a valid
authenticated identity from the request.
Check:
- Is the Authorization header present?
- Does it start with
Bearer? - Has the token expired?
- Does the signing key match?
- Are the issuer and audience correct?
- Is JWT Bearer authentication configured?
403 Forbidden
A 403 usually means authentication succeeded but the authenticated
user does not meet the authorization requirement.
For example, an endpoint requiring the Admin role will reject an
authenticated user who does not have that role.
Invalid Signature
Verify that the API is validating the token using the expected signing key and algorithm. A token signed with a different key should fail validation.
Token Expired
Check the token's expiration and the server clock. In this example we set:
ClockSkew = TimeSpan.Zero
That removes the default clock-skew allowance, so expiration is enforced without that additional tolerance.
Authentication Works Until [Authorize] Is Added
Check the middleware configuration and make sure authentication is executed before authorization:
app.UseAuthentication();
app.UseAuthorization();
JWT vs Cookie Authentication
JWT Bearer authentication and cookie authentication solve related problems, but neither one is automatically better for every application.
| Scenario | JWT Bearer | Cookies |
|---|---|---|
| Web APIs | Common choice | Also possible |
| Traditional server-rendered web apps | Possible | Very common |
| Automatically sent by browser | Depends on storage/client design | Yes, according to cookie rules |
| Server-side session required | Not inherently | Not inherently |
| Native/mobile API clients | Common | Less typical |
Choose an authentication approach based on the architecture and security requirements of your application rather than assuming JWT is always the better option.
Frequently Asked Questions
What is JWT authentication in ASP.NET Core?
JWT authentication allows ASP.NET Core to authenticate requests using a signed token supplied by the client, commonly through the HTTP Authorization header using the Bearer authentication scheme.
Does a JWT encrypt my data?
Not necessarily. A standard signed JWT protects integrity but its payload can generally be decoded by anyone who has the token. Sensitive information should not be placed in the payload merely because the token is signed.
Where should I store a JWT?
There is no single storage strategy that is correct for every application. The appropriate choice depends on whether the client is a browser, mobile app, server application or another type of client, as well as the threats you need to protect against.
In browser applications, consider the security implications of both JavaScript-accessible storage and cookies before choosing an approach.
Why am I getting 401 Unauthorized?
Common causes include an expired token, incorrect signing key, invalid issuer or audience, a malformed Authorization header, or incorrect JWT Bearer configuration.
What is the difference between 401 and 403?
In simple terms, 401 Unauthorized generally means the request
could not be authenticated successfully. 403 Forbidden generally
means the authenticated identity does not have permission to perform the
requested action.
Can JWT be used with ASP.NET Core Identity?
Yes. ASP.NET Core Identity can manage users and credentials while your API authentication architecture can issue and validate tokens as appropriate. The exact implementation depends on the application's authentication design.
Can a JWT be revoked?
A self-contained access token does not automatically contact a central session store on every request. If immediate revocation is required, your architecture needs an appropriate revocation strategy. Short token lifetimes and carefully designed refresh-token management are common parts of such a design.
How long should a JWT access token last?
There is no universal lifetime that is correct for every application. Shorter lifetimes reduce the period during which a stolen access token can be used, while very short lifetimes can increase refresh activity. Choose the lifetime based on the application's risk profile and authentication design.
Complete Authentication Flow
At this point our example works as follows:
- The client sends an email and password to
/api/auth/login. - The demo endpoint validates the credentials.
TokenServicecreates and signs a JWT.- The API returns the access token.
- The client sends the token in the Authorization header.
- JWT Bearer middleware validates the token.
[Authorize]allows authenticated requests to the protected endpoint.- Role requirements can further restrict access to specific users.
Conclusion
You have now built a basic JWT authentication flow in ASP.NET Core. We created
an API, configured JWT Bearer authentication, generated a signed access token,
protected an endpoint with [Authorize], and added role-based
authorization.
The example intentionally keeps user validation simple so the JWT flow is easy to understand. For a production application, the next step is to integrate a proper identity system, secure key management, appropriate token lifetimes, refresh-token handling where required, and application-specific authorization policies.
When troubleshooting JWT authentication, avoid weakening validation just to make a token work. Check the signing key, issuer, audience, lifetime, authentication scheme and middleware configuration individually.