By AndyPublished
ASP.NET Core JWT Authentication for .NET Web APIs (.NET 8 to .NET 10)
Microsoft.AspNetCore.Authentication.JwtBearer, callAddAuthentication().AddJwtBearer(...) with the token issuer (Authority) and your API's Audience, then protect endpoints with [Authorize] or RequireAuthorization(). The handler fetches the issuer's signing keys, checks signature, issuer, audience and lifetime, and fillsHttpContext.User from the claims. This guide covers the configuration, theJsonWebTokenHandler versus JwtSecurityTokenHandler question, roles and the claim-mapping gotcha, local testing with dotnet user-jwts, refresh tokens and logout. Checked against the Microsoft Learn ASP.NET Core docs for .NET 10 (which also cover 8 and 9) and Microsoft.IdentityModel 8.19.JWT in a .NET Core Web API: Minimal Setup
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://idp.example.com/realms/acme"; // the token issuer
options.Audience = "https://api.example.com"; // this API's identifier
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/orders", (ClaimsPrincipal user) => $"Orders for {user.FindFirst("sub")?.Value}")
.RequireAuthorization();
app.Run();Authority makes the handler read{Authority}/.well-known/openid-configuration, take the issuer andjwks_uri from it, and cache the keys. Audience sets the expected aud. Microsoft's guidance is that an API should validate the signature,iss, aud and expiry, and return 401 if any of them fail. If your provider does not use the standard path, set MetadataAddress explicitly. The background on authorities and discovery is in JWTs in OAuth 2.0 and OpenID Connect.
AddJwtBearer() with no delegate also binds from configuration, from theAuthentication:Schemes:Bearer section (ValidAudiences,ValidIssuer, and so on). This is the section dotnet user-jwts writes to.
TokenValidationParameters You Should Set
builder.Services.AddAuthentication()
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Api:Authority"];
options.Audience = builder.Configuration["Api:Audience"];
options.MapInboundClaims = false; // keep claim names as issued
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
ClockSkew = TimeSpan.FromSeconds(30), // default is 5 minutes
NameClaimType = "sub",
RoleClaimType = "roles",
};
});- ·ValidAlgorithms pins the signature algorithm, so a token that claims a different
algis rejected before any key is tried. - ·ClockSkew defaults to five minutes, which means an "expired" token keeps working for up to five minutes. Most teams reduce it. See clock skew for how much leeway is sensible.
- ·ValidAudiences / ValidIssuers accept lists when an API serves several clients or tenants.
Microsoft's docs recommend keeping to the defaults where you can and setting only what your provider needs. Never set ValidateIssuerSigningKey, ValidateLifetime orValidateAudience to false to "fix" a failing token. Find the mismatch instead; the JWT debugging routine walks through it.
JWT C# Example: Issuing and Validating Your Own Tokens
For closed systems and tests, JsonWebTokenHandler creates a signed JWT from aSecurityTokenDescriptor:
using System.Security.Claims;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
var key = new SymmetricSecurityKey(Convert.FromBase64String(config["Jwt:Key"]!)); // 32+ random bytes
string token = new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{
Issuer = "https://api.example.com",
Audience = "https://api.example.com",
Subject = new ClaimsIdentity(new[]
{
new Claim("sub", userId),
new Claim("roles", "Admin"),
}),
Expires = DateTime.UtcNow.AddMinutes(15),
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
});The API then validates with the same key instead of an Authority:
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "https://api.example.com",
ValidAudience = "https://api.example.com",
IssuerSigningKey = key,
ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 },
ClockSkew = TimeSpan.FromSeconds(30),
RoleClaimType = "roles",
};
});Generate the key with a CSPRNG, not a memorable passphrase (see the JWT secret key generator), and keep it in user secrets or a vault rather than appsettings.json. To check what you produced, paste the token into the jwtdecode.app decoder, which decodes it and verifies HS256 locally in your browser.
Which JWT C# Library? JsonWebTokenHandler vs JwtSecurityTokenHandler
| Package | What it is for |
|---|---|
| Microsoft.AspNetCore.Authentication.JwtBearer | The authentication handler: reads the bearer header, validates the token, builds HttpContext.User. This is the one a Web API references. |
| Microsoft.IdentityModel.JsonWebTokens | JsonWebTokenHandler and JsonWebToken. Creates and validates JWTs; used by JwtBearer by default since ASP.NET Core 8. |
| System.IdentityModel.Tokens.Jwt | JwtSecurityTokenHandler and JwtSecurityToken, the previous generation. Still published, but Microsoft describes JsonWebTokens as the newer, faster replacement. |
| Microsoft.Identity.Web | Wrapper for Microsoft Entra ID that configures JwtBearer for you (issuer rules, v1/v2 tokens, scopes and app roles). |
In new code, use JsonWebTokenHandler. Since ASP.NET Core 8 the JwtBearer handler uses it by default. Microsoft gives three reasons for the switch: about 30% better performance, "last known good" metadata for reliability, and async processing. The visible side effect is thatTokenValidatedContext.SecurityToken is now a JsonWebToken, so event code that casts it to JwtSecurityToken gets null. Cast toJsonWebToken instead, or set options.UseSecurityTokenValidators = trueto restore the old behaviour while you migrate.
JWT Roles in C#: [Authorize(Roles = ...)] and Claim Mapping
[Authorize(Roles = "Admin")]
[ApiController, Route("admin")]
public class AdminController : ControllerBase { /* ... */ }
// Minimal APIs, with a scope policy as well.
// "scope" is usually one space-separated string, so match individual values.
builder.Services.AddAuthorizationBuilder()
.AddPolicy("orders.read", p => p.RequireAssertion(ctx =>
ctx.User.FindAll("scope")
.SelectMany(c => c.Value.Split(' '))
.Contains("orders.read")));
app.MapGet("/orders", () => "...").RequireAuthorization("orders.read");
app.MapDelete("/orders/{id}", (int id) => "...").RequireAuthorization(p => p.RequireRole("Admin"));[Authorize(Roles = "Admin")] and RequireRole check claims of the identity's role claim type, not a claim literally named role. That is where most "my token has the role but I get 403" reports come from. Likewise,RequireClaim("scope", "orders.read") compares whole claim values, so it fails against a token whose scope is "orders.read orders.write"; that is why the policy above splits the string.
The MapInboundClaims gotcha
By default the handler renames well-known JWT claims to the long WS-* claim type URIs, usingJsonWebTokenHandler.DefaultInboundClaimTypeMap (on .NET 8 and later). For example,sub becomes ClaimTypes.NameIdentifier. Code that callsUser.FindFirst("sub") then finds nothing. You have two consistent options:
- ·Keep the mapping and read claims through
ClaimTypes.*. - ·Turn it off with
options.MapInboundClaims = false(per scheme) orJsonWebTokenHandler.DefaultInboundClaimTypeMap.Clear()(globally). Then tell ASP.NET Core which claims carry the name and roles, withTokenValidationParameters.NameClaimTypeandRoleClaimType. Entra ID puts app roles inroles; other providers userole,groupsor a namespaced claim.
The second option is easier to reason about, because what you see in the decoded token is whatUser contains. For the difference between scopes and roles, see scopes vs roles.
JWT in .NET 8 and .NET 10: What to Know
- ·.NET 8 made
JsonWebToken/JsonWebTokenHandlerthe default in JwtBearer, as described above. Claim-map code that editedJwtSecurityTokenHandler.DefaultInboundClaimTypeMapno longer has any effect; editJsonWebTokenHandler.DefaultInboundClaimTypeMapinstead. - ·.NET 8 Identity API endpoints (
AddIdentityApiEndpointsplusMapIdentityApi) issue bearer and refresh tokens from/loginand/refresh. Microsoft states these are proprietary tokens, not JWTs. You cannot decode them or validate them in another service with JwtBearer. - ·.NET 10 is the current LTS release. The JwtBearer configuration shown in this guide is the same on .NET 8, 9 and 10; the Microsoft Learn pages cited here cover all three.
Testing Locally with dotnet user-jwts
The dotnet user-jwts tool (available since .NET 7) creates development tokens signed with a key it stores in your user secrets. It also writes the matching issuer and audiences intoappsettings.Development.json, so a bare AddJwtBearer() accepts them.
dotnet user-jwts create --name alice --role Admin --scope "orders.read" --valid-for 1h dotnet user-jwts list dotnet user-jwts print <id> --show-all curl -i -H "Authorization: Bearer <token>" https://localhost:7076/orders
The default issuer is dotnet-user-jwts. These tokens exist only for the Development environment; make sure production configuration never trusts that issuer or key. The tool is also handy for reproducing authorisation bugs: create one token with the role and one without, and confirm that the second gets a 403 rather than a 401.
JWT Refresh Tokens in C#
JwtBearer only validates tokens; it has no refresh endpoint. Where refresh tokens come from depends on who issues the access token:
- ·An OpenID Connect provider: the client requests the
offline_accessscope (or the provider's equivalent) and calls the provider's token endpoint withgrant_type=refresh_token. Your API does nothing. - ·Identity API endpoints: call
POST /refreshwith the refresh token from/login. - ·Self-issued JWTs: store a hash of a random refresh token per user and device, rotate it on every use, and revoke the whole family if an old one is replayed. The refresh token pattern sets out the rules.
JWT Logout in C#
A signed JWT stays valid until exp. Logout therefore means deleting the tokens on the client, revoking the refresh token, and keeping access tokens short. If you must cut off an access token immediately, check a deny-list of jti values after validation:
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var jti = context.Principal?.FindFirst("jti")?.Value;
var denyList = context.HttpContext.RequestServices.GetRequiredService<ITokenDenyList>();
if (jti is null || await denyList.IsRevokedAsync(jti))
{
context.Fail("Token has been revoked.");
}
}
};ITokenDenyList is your own abstraction, usually backed by Redis with entries that expire at the token's exp. This adds a lookup to every request, which is the stateful cost that JWTs were meant to avoid, so use it only where it is needed. JWT logout and revocation compares the options.
Common ASP.NET Core JWT Mistakes
- ·Middleware order.
UseAuthentication()must run beforeUseAuthorization(), and both afterUseRouting()when you call it explicitly. If they are in the wrong order,Useris always anonymous. - ·Sending the ID token. The ASP.NET Core docs say ID tokens should never be used to access APIs. An ID token's
audis the client's ID, so a correctly configured API rejects it with an audience error. Have the client send the access token for your API. - ·Turning off HTTPS metadata.
RequireHttpsMetadatadefaults to true. Setting it to false outside local development lets anyone who can tamper with the metadata request supply their own signing keys. - ·Multiple schemes without a default. If you register more than one scheme, set the default or name the scheme on
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]. Otherwise the bearer handler may never run. - ·Reading 401 and 403 the same way. A 401 means the token failed validation, and the
WWW-Authenticateresponse header usually says why. A 403 means the token was fine but the policy failed, which almost always comes down to role or scope claim types. Enable logging forMicrosoft.AspNetCore.Authenticationto see the exactIDXerror.
Summary
ASP.NET Core JWT authentication is AddAuthentication().AddJwtBearer() with anAuthority and Audience, plus a fewTokenValidationParameters: pinned algorithms, a shorter ClockSkew, and explicit name and role claim types. Set MapInboundClaims = false so claim names match the token. Use JsonWebTokenHandler rather than the legacyJwtSecurityTokenHandler. Issue your own tokens only in tests or closed systems, test locally withdotnet user-jwts, and handle logout with short lifetimes and refresh-token revocation.