Authentication with JWT in ASP.NET Core Web API: Issuing and Validating Tokens
From the authentication vs authorization split to the anatomy of a JWT, we build a login flow with AddJwtBearer and JsonWebTokenHandler. ClockSkew, claim mapping and the refresh token idea are covered too.
Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.
Guess First
At the end of the previous post we left a gap open: anyone could replace a product image, because the API did not know who sent the request. This post answers that question. The user logs in with an email and a password, the API hands them a signed JWT, and the user shows that token on every request after that.
First, a guess. You have a token whose lifetime was cut from 15 minutes to 1 minute for testing. The token's exp value says 21:22:33. At 21:22:51, when the token expired 18 seconds ago, you send a request to a protected endpoint. Do you get a 200 or a 401? The answer is in the section "The Login Endpoint and the Language of 401".
Authentication vs Authorization
The two words look so alike that they get mixed up all the time, but they answer different questions. Authentication asks "who are you?". Authorization asks "I know who you are, but are you allowed to do this?".
Think of an airport. At passport control the officer looks at your face and your passport; that is authentication. The agent at the boarding gate looks at your boarding pass and decides whether you can board this plane; that is authorization. Your passport may be valid, but you still cannot walk through another flight's gate.
In HTTP this split shows up in the status codes. If you have not proved who you are, you get 401 Unauthorized (a misleading name; it really means "unauthenticated"). If we know who you are but you lack permission, you get 403 Forbidden. This post is about the first gate; roles, policies and 403 are the topic of the next one.
Anatomy of a JWT
The token the API returns on login looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5jb20iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwianRpIjoiNmM1ZGFlMzctMTQyNi00NTczLTgwY2YtNDU5MDgwM2Q3ZGUwIiwiYXVkIjoiUHJvZHVjdENhdGFsb2cuQ2xpZW50cyIsImV4cCI6MTc5Mzc0MTc3MiwiaXNzIjoiUHJvZHVjdENhdGFsb2cuQXBpIiwiaWF0IjoxNzkzNzQwODcyLCJuYmYiOjE3OTM3NDA4NzJ9.1NlwHITl09ZAkKYzMIwpaSD7VK0wQ3-F02vuVU8GDJw
There are three parts separated by dots: header, payload and signature. The first two are not encrypted, only JSON encoded with base64url. When I decoded the first two parts of this token, I got this:
{
"alg": "HS256",
"typ": "JWT"
}
{
"sub": "1",
"email": "ada@example.com",
"name": "Ada Lovelace",
"jti": "6c5dae37-1426-4573-80cf-4590803d7de0",
"aud": "ProductCatalog.Clients",
"exp": 1793741772,
"iss": "ProductCatalog.Api",
"iat": 1793740872,
"nbf": 1793740872
}
The header says the signature uses HMAC-SHA256. Every field in the payload is a claim. sub is the user's identity, iss says who issued the token, aud says who the token was issued for. exp, iat and nbf are Unix timestamps: expiry, issued-at and "not valid before", in that order. jti is the token's unique id.
The third part, the signature, is a digest of the first two parts computed with a secret key. Anyone can read the token, but nobody without the key can change its content and produce a valid signature. Compare it to a notarized document: everyone can read the text, but if the seal is fake, the document is rejected. That is why you never put anything secret, like a password or a card number, in the payload.
The Package and Program.cs
JWT validation is not in the shared framework; it lives in a separate NuGet package. The package's major version has to match the .NET version your project targets:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 6.0.36
6.0.36 is the last release of the .NET 6 line; in a .NET 10 project you use version 10.0.14 of the same package. If you try to add it to a .NET 6 project without --version, NuGet picks the newest version and stops with this error:
error: NU1202: Package Microsoft.AspNetCore.Authentication.JwtBearer 10.0.14 is not compatible with net6.0 (.NETCoreApp,Version=v6.0). Package Microsoft.AspNetCore.Authentication.JwtBearer 10.0.14 supports: net10.0 (.NETCoreApp,Version=v10.0)
We keep the settings in configuration and read them with the Options pattern:
namespace ProductCatalog.Auth;
public class JwtOptions
{
public const string SectionName = "Jwt";
public string Issuer { get; set; } = string.Empty;
public string Audience { get; set; } = string.Empty;
public string SigningKey { get; set; } = string.Empty;
public int AccessTokenMinutes { get; set; } = 15;
}
We put Issuer, Audience and AccessTokenMinutes into appsettings.json. SigningKey, however, must not go into the repository; use user secrets in development and an environment variable or a secret store in production. In this example I put a development-only key into appsettings.Development.json. Now Program.cs:
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using ProductCatalog.Auth;
var builder = WebApplication.CreateBuilder(args);
var jwtSection = builder.Configuration.GetSection(JwtOptions.SectionName);
builder.Services.Configure<JwtOptions>(jwtSection);
var jwt = jwtSection.Get<JwtOptions>()!;
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// The rules we use to validate an incoming token
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwt.Issuer,
ValidateAudience = true,
ValidAudience = jwt.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SigningKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization();
builder.Services.AddSingleton<UserStore>();
builder.Services.AddSingleton<ITokenService, TokenService>();
builder.Services.AddControllers();
var app = builder.Build();
// Order matters: first find out who you are, then check what you may do
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
AddAuthentication sets the default scheme to Bearer, and AddJwtBearer registers the handler for that scheme. Each Validate... line inside TokenValidationParameters is a check: did our API issue this token, was it issued for our clients, is the signature correct, has it expired? The UseAuthentication middleware reads and validates the token in the Authorization header on every request and writes the result to HttpContext.User. We talked about middleware order in the pipeline post; here too authentication comes first, then authorization.
Issuing Tokens: JwtSecurityTokenHandler or JsonWebTokenHandler?
Most .NET 6 samples you find online issue the token like this:
var token = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
expires: expiresAt,
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
return new TokenResponse(new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
This code still compiles and runs; I tried it on both .NET 6 and .NET 10. But the NuGet description of the System.IdentityModel.Tokens.Jwt package is now explicit: as of IdentityModel 7.x the package is considered legacy, and Microsoft.IdentityModel.JsonWebTokens is recommended instead. Validation moved the same way. The JwtBearer package for .NET 6 validates tokens with the JwtSecurityTokenHandler in its SecurityTokenValidators list. In .NET 8 and later the default TokenHandlers list holds a JsonWebTokenHandler; the old behavior can be switched back on with the UseSecurityTokenValidators setting.
The good news: JsonWebTokenHandler already comes into your project along with the JwtBearer package, version 6.35.0 on .NET 6 and 8.19.2 on .NET 10. And the same code compiles on both. So that is what we use in new code:
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
namespace ProductCatalog.Auth;
public record TokenResponse(string AccessToken, DateTime ExpiresAt);
public interface ITokenService
{
TokenResponse CreateAccessToken(AppUser user);
}
public class TokenService : ITokenService
{
private readonly JwtOptions _options;
public TokenService(IOptions<JwtOptions> options)
{
_options = options.Value;
}
public TokenResponse CreateAccessToken(AppUser user)
{
var expiresAt = DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes);
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
var descriptor = new SecurityTokenDescriptor
{
Issuer = _options.Issuer,
Audience = _options.Audience,
Expires = expiresAt,
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
// The claims the token will carry
Subject = new ClaimsIdentity(new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim("name", user.DisplayName),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
})
};
var token = new JsonWebTokenHandler().CreateToken(descriptor);
return new TokenResponse(token, expiresAt);
}
}
SecurityTokenDescriptor describes the token; the handler adds the iat and nbf fields by itself. The payload we decoded above was the output of this code.
The Login Endpoint and the Language of 401
For users we use a small in-memory store for now. In a real project this lives in a database, but even here we do not break one rule: passwords are never stored as plain text. PasswordHasher<TUser> in the Microsoft.AspNetCore.Identity namespace is part of the shared framework and needs no extra package:
public AppUser? ValidateCredentials(string email, string password)
{
var user = _users.FirstOrDefault(u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase));
if (user is null)
{
return null;
}
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, password);
return result == PasswordVerificationResult.Failed ? null : user;
}
The controller side:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Auth;
namespace ProductCatalog.Controllers;
public record LoginRequest(string Email, string Password);
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private readonly UserStore _users;
private readonly ITokenService _tokens;
public AuthController(UserStore users, ITokenService tokens)
{
_users = users;
_tokens = tokens;
}
[HttpPost("login")]
[AllowAnonymous]
public ActionResult<TokenResponse> Login(LoginRequest request)
{
var user = _users.ValidateCredentials(request.Email, request.Password);
if (user is null)
{
// We do not say which piece of information was wrong
return Unauthorized();
}
return Ok(_tokens.CreateAccessToken(user));
}
[HttpGet("me")]
[Authorize]
public IActionResult Me()
{
var claims = User.Claims.Select(c => new { c.Type, c.Value });
return Ok(claims);
}
}
I also added [Authorize] to the POST action in ProductsController; GET stays open to everyone. Let's first try to add a product without a token, then log in:
curl -i -X POST http://localhost:5080/api/products -H "Content-Type: application/json" -d '{"id":3,"name":"USB-C Hub","price":900}'
HTTP/1.1 401 Unauthorized
Content-Length: 0
Server: Kestrel
WWW-Authenticate: Bearer
curl -X POST http://localhost:5080/api/auth/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"Pa55word!"}'
{"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJhZGFAZXhhbXBsZS5jb20iLCJuYW1lIjoiQWRhIExvdmVsYWNlIiwianRpIjoiNmM1ZGFlMzctMTQyNi00NTczLTgwY2YtNDU5MDgwM2Q3ZGUwIiwiYXVkIjoiUHJvZHVjdENhdGFsb2cuQ2xpZW50cyIsImV4cCI6MTc5Mzc0MTc3MiwiaXNzIjoiUHJvZHVjdENhdGFsb2cuQXBpIiwiaWF0IjoxNzkzNzQwODcyLCJuYmYiOjE3OTM3NDA4NzJ9.1NlwHITl09ZAkKYzMIwpaSD7VK0wQ3-F02vuVU8GDJw","expiresAt":"2026-11-03T21:36:12.501897Z"}
When you repeat the same POST request with -H "Authorization: Bearer <token>", you get 201 Created and the new product. If you try to log in with a wrong password, [ApiController] turns the Unauthorized() result into ProblemDetails and you get a body containing "title":"Unauthorized","status":401.
The body of a 401 is empty, but the WWW-Authenticate header is chatty. If you do not send a token at all, or forget the Bearer prefix, it just says Bearer; the handler never saw a token to validate. When I changed the last character of the signature, I got error="invalid_token", error_description="The signature key was not found". And for an expired token:
WWW-Authenticate: Bearer error="invalid_token", error_description="The token expired at '11/03/2026 21:22:33'"
Here is the answer to the opening question. The request I sent at 21:22:51 with a token whose exp said 21:22:33 returned 200. At 21:23:08 I got the 401 above. The reason is ClockSkew: so that tokens are not rejected because of small differences between server clocks, the validator adds a tolerance to the expiry time. We set 30 seconds; the default is 5 minutes. So without this setting your 15-minute token would effectively live for 20 minutes.
Why Do the Claims Change Their Names?
Look at the response of the /api/auth/me endpoint:
[{"type":"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier","value":"1"},{"type":"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress","value":"ada@example.com"},{"type":"name","value":"Ada Lovelace"},{"type":"jti","value":"6c5dae37-1426-4573-80cf-4590803d7de0"},{"type":"aud","value":"ProductCatalog.Clients"},{"type":"exp","value":"1793741772"},{"type":"iss","value":"ProductCatalog.Api"},{"type":"iat","value":"1793740872"},{"type":"nbf","value":"1793740872"}]
We put sub and email into the token, yet User holds long names with XML namespaces. Because the MapInboundClaims setting defaults to true, JwtBearer translates the short JWT claim names into the old WS-Federation names. The result: User.FindFirst("sub") returns null, while User.FindFirst(ClaimTypes.NameIdentifier) works. The behavior is the same on .NET 10; only the order of the claims differs.
There are two ways out. Either accept the mapping and use the ClaimTypes constants in code, or turn the mapping off:
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
With this line the me response shows {"type":"sub","value":"1"},{"type":"email","value":"ada@example.com"}. Having the same name in the token and in the code means fewer surprises, in my view. Whichever you choose, pick one way for the whole project.
The Refresh Token Idea
Keeping the access token short-lived is good for security: even if it is stolen, it is useless a few minutes later. But you cannot make the user type their password every 15 minutes. The solution is a second token: the refresh token.
A refresh token is not a JWT but an unguessable random value. It is handed out together with the access token at login and stored on the server, linked to the user. When the access token expires, the client sends the refresh token to a separate endpoint; the server compares it with its own record and, if it is valid, issues a new access token and a new refresh token and revokes the old one. This is called rotation; it lets you notice when a stolen refresh token is used a second time. A few lines are enough to generate one:
using System.Security.Cryptography;
using System.Text;
namespace ProductCatalog.Auth;
public static class RefreshTokenFactory
{
// A cryptographically secure 64-byte random value
public static string Create() =>
Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
// Store the hash in the database, not the token itself
public static string Hash(string refreshToken) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken)));
}
Storage, lifetime, revocation and details like "sign out from all devices" depend on your database design. Here it is enough to know the idea; I leave the full flow to you in A Small Experiment.
Common Pitfalls
A short signing key. When I tried to issue a token with the 13-character my-secret-key, I got IDX10653: The encryption algorithm 'HS256' requires a key size of at least '128' bits. With a 20-character key the error became IDX10720: for HS256 the key must be 256 bits, that is at least 32 bytes. Generate the key randomly; do not make one up by hand.
Forgetting UseAuthentication or placing it after UseAuthorization. On .NET 6, when I deleted the line I got a 401 even with a valid token, because User stays empty. The same experiment on .NET 10 returned 200; newer versions of WebApplication add the missing middleware themselves. Write the line explicitly anyway. When I reversed the order, I got a 401 on both versions.
Setting ValidateIssuer, ValidateAudience or ValidateLifetime to false "to make it work". Tokens issued for another system, or tokens that expired days ago, get accepted too.
Believing the payload is secret. Anyone can decode base64url; the user can read everything you put in the token.
Mixing package versions. JwtBearer's major version must match the target framework: 6.0.x for .NET 6, 10.0.x for .NET 10.
A Small Experiment
★☆☆ Start the app with the Jwt__AccessTokenMinutes=1 environment variable, get a token and, after it expires, watch the ClockSkew tolerance run out. Then delete the ClockSkew line and repeat the experiment.
★★☆ Set MapInboundClaims = false and make the me endpoint return only the user's sub and email values. Observe why the same code returns null while the mapping is on.
★★★ Build the refresh token flow: add a refresh token to the login response, store its hash with the user, apply rotation in a POST api/auth/refresh endpoint and reject the same refresh token if it arrives a second time.
The API now knows who is talking. But a valid token does not mean Ada should be able to do everything; deleting products should perhaps be open only to administrators. In the next post we move on to authorization and use roles, claims and policies to define who is allowed to do what.