By AndyPublished

ASP.NET Core JWT Authentication for .NET Web APIs (.NET 8 to .NET 10)

To add JWT authentication to an ASP.NET Core Web API, referenceMicrosoft.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 alg is 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

⚠
The ASP.NET Core docs are blunt about this: don't generate your own access tokens except for testing, use asymmetric keys when you do, and never mint an access token directly from a username and password request. Use an OpenID Connect provider (Entra ID, Keycloak, Duende IdentityServer, OpenIddict, Auth0 and so on) for production sign-in.

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

PackageWhat it is for
Microsoft.AspNetCore.Authentication.JwtBearerThe authentication handler: reads the bearer header, validates the token, builds HttpContext.User. This is the one a Web API references.
Microsoft.IdentityModel.JsonWebTokensJsonWebTokenHandler and JsonWebToken. Creates and validates JWTs; used by JwtBearer by default since ASP.NET Core 8.
System.IdentityModel.Tokens.JwtJwtSecurityTokenHandler and JwtSecurityToken, the previous generation. Still published, but Microsoft describes JsonWebTokens as the newer, faster replacement.
Microsoft.Identity.WebWrapper 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) or JsonWebTokenHandler.DefaultInboundClaimTypeMap.Clear() (globally). Then tell ASP.NET Core which claims carry the name and roles, with TokenValidationParameters.NameClaimType and RoleClaimType. Entra ID puts app roles in roles; other providers use role, groups or 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/JsonWebTokenHandler the default in JwtBearer, as described above. Claim-map code that edited JwtSecurityTokenHandler.DefaultInboundClaimTypeMap no longer has any effect; edit JsonWebTokenHandler.DefaultInboundClaimTypeMap instead.
  • ·.NET 8 Identity API endpoints (AddIdentityApiEndpoints plus MapIdentityApi) issue bearer and refresh tokens from /login and /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_access scope (or the provider's equivalent) and calls the provider's token endpoint with grant_type=refresh_token. Your API does nothing.
  • ·Identity API endpoints: call POST /refresh with 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 before UseAuthorization(), and both after UseRouting() when you call it explicitly. If they are in the wrong order, User is 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 aud is 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. RequireHttpsMetadata defaults 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-Authenticate response 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 for Microsoft.AspNetCore.Authentication to see the exact IDX error.

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.

Ready to decode a token?
Use the free JWT decoder — paste any token for instant results, entirely in your browser.
Open JWT Decoder