JWT Bearer Tokens

Validate access tokens from an identity provider with AddJwtBearer: issuer, audience, signature, lifetime and the claims your API relies on.

Syntaxbuilder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(o => { o.Authority = "https://login.example.com/realms/shop"; o.Audience = "orders-api"; });

A resource API does not log anyone in. It receives Authorization: Bearer <token>, proves the token was issued by a provider it trusts, for this API, and has not expired, then turns its claims into a ClaimsPrincipal. AddJwtBearer (package Microsoft.AspNetCore.Authentication.JwtBearer) does this with JsonWebTokenHandler.

Authority, metadata and keys

Set Authority to the issuer URL. On the first request the handler downloads {Authority}/.well-known/openid-configuration and the jwks_uri it points to, and caches both: AutomaticRefreshInterval is 12 hours, and an unknown kid triggers a refresh (RefreshOnIssuerKeyNotFound, at most every RefreshInterval, 5 minutes). That is how key rotation works without a deploy. RequireHttpsMetadata is true. If the API cannot reach the provider before it has ever loaded the metadata, bearer requests fail with a 500 (IDX20803, unable to obtain configuration). After that, the cached metadata is used until a refresh succeeds, so tokens signed with a new key are rejected with 401 until the provider is reachable again. Treat the provider as a dependency in your network policy.

What must be validated

CheckDefaultWhy it matters
SignatureRequireSignedTokens true; unsigned (alg: none) rejectedAnyone can write a JWT; only the provider can sign one
Issuer (iss)From the metadata when Authority is setRejects tokens from another tenant or realm
Audience (aud)Audience or ValidAudiences; none set means every token failsA token for billing-api from the same provider must not open orders-api
Lifetime (exp, nbf)ClockSkew 5 minutesA token that expired 4 minutes ago is still accepted
AlgorithmAny the key supportsPin ValidAlgorithms to what the provider uses, such as RS256

Validation proves who issued the token. What the caller may do is authorization: check scope (or scp on Entra ID) and roles in policies, not in the handler.

Claims you will actually see

MapInboundClaims is true by default, which renames sub to ClaimTypes.NameIdentifier and role to ClaimTypes.Role, while NameClaimType stays the long SOAP URI, so User.Identity.Name is null for a token carrying name. New APIs set MapInboundClaims = false and NameClaimType = "name" so claim names in code match the token.

Configuration and failure modes

AddJwtBearer() also binds Authentication:Schemes:Bearer (Authority, ValidAudiences, ValidIssuer, MapInboundClaims), so per-environment values live in configuration. IncludeErrorDetails is true, so a rejected token gets WWW-Authenticate: Bearer error="invalid_token", error_description="The token expired at ...", which is safe and saves support time. The incidents teams hit: audience left unset in a copied config (all 401s), clock skew reduced to zero on hosts with drifting clocks (random 401s), and an ID token sent as an access token and accepted because nothing checked the audience or token type. Log OnAuthenticationFailed at warning level; the handler itself logs failures only at information level.

Example

Example · csharp
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"]; // https://login.example.com/realms/shop
        options.Audience = builder.Configuration["Auth:Audience"];   // orders-api
        options.MapInboundClaims = false;                             // keep "sub", "scope", "role" as sent

        options.TokenValidationParameters.NameClaimType = "name";
        options.TokenValidationParameters.RoleClaimType = "role";
        options.TokenValidationParameters.ValidAlgorithms = [SecurityAlgorithms.RsaSha256];
        options.TokenValidationParameters.ClockSkew = TimeSpan.FromSeconds(60);

        options.Events = new JwtBearerEvents
        {
            OnAuthenticationFailed = context =>
            {
                var logger = context.HttpContext.RequestServices
                    .GetRequiredService<ILoggerFactory>().CreateLogger("Auth.JwtBearer");
                logger.LogWarning("Bearer token rejected: {Reason}", context.Exception.GetType().Name);
                return Task.CompletedTask;
            },
        };
    });

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("orders:read", policy => policy.RequireAssertion(ctx => Scopes.Has(ctx.User, "orders.read")))
    .AddPolicy("orders:write", policy => policy.RequireAssertion(ctx => Scopes.Has(ctx.User, "orders.write")));

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/orders/mine", (ClaimsPrincipal user) =>
        TypedResults.Ok(new { customerId = user.FindFirstValue("sub"), name = user.Identity?.Name }))
    .RequireAuthorization("orders:read");

app.MapPost("/orders", (CreateOrder order, ClaimsPrincipal user) =>
        TypedResults.Created($"/orders/1001", new { id = 1001, customerId = user.FindFirstValue("sub"), order.Sku, order.Quantity }))
    .RequireAuthorization("orders:write");

app.Run();

record CreateOrder(string Sku, int Quantity);

static class Scopes
{
    // "scope" is one space-separated claim (RFC 8693 style); Entra ID uses "scp"; some providers send one claim per scope.
    public static bool Has(ClaimsPrincipal user, string scope) =>
        user.FindAll("scope").Concat(user.FindAll("scp"))
            .SelectMany(claim => claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
            .Contains(scope, StringComparer.Ordinal);
}

When to use it

  • Accept access tokens from a Keycloak realm for orders-api and reject tokens the same realm issued for billing-api.
  • Survive the identity provider rotating its signing key on a Sunday without restarting any API pod.
  • Require scope orders.write on POST /orders while GET /orders/mine needs only orders.read.

More examples

Settings in configuration, per environment

With this section, <code>AddJwtBearer()</code> needs no lambda at all, and staging can point at a different realm without a code change. The section name is <code>Authentication:Schemes:{scheme name}</code>, so a scheme registered as <code>AddJwtBearer("Partners")</code> reads <code>Authentication:Schemes:Partners</code>. Values set in a configure lambda run after the binding and win. Raising the authentication log category to Information shows why each token was rejected.

Example · json
{
  // appsettings.Production.json; AddJwtBearer() binds this section by scheme name.
  "Authentication": {
    "Schemes": {
      "Bearer": {
        "Authority": "https://login.example.com/realms/shop",
        "ValidAudiences": [ "orders-api" ],
        "ValidIssuer": "https://login.example.com/realms/shop",
        "MapInboundClaims": false
      }
    }
  },
  "Logging": {
    "LogLevel": {
      "Microsoft.AspNetCore.Authentication": "Information"
    }
  }
}

A 401 body that clients can use

By default a bearer 401 has an empty body and all detail is in <code>WWW-Authenticate</code>. Teams that promise ProblemDetails for every error override <code>OnChallenge</code>, and must call <code>HandleResponse()</code> or the handler writes its own response after yours. Keep the detail generic: say expired or invalid, never echo the token or the exception message, which can include configuration details.

Example · csharp
using Microsoft.AspNetCore.Authentication.JwtBearer;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"];
        options.Audience = "orders-api";
        options.Events = new JwtBearerEvents
        {
            OnChallenge = async context =>
            {
                // The handler has not written WWW-Authenticate yet and HandleResponse() stops it,
                // so rebuild the header here, then write a ProblemDetails body.
                context.HandleResponse();
                var response = context.Response;
                response.StatusCode = StatusCodes.Status401Unauthorized;
                response.Headers.WWWAuthenticate = string.IsNullOrEmpty(context.Error)
                    ? "Bearer"
                    : string.IsNullOrEmpty(context.ErrorDescription)
                        ? $"Bearer error=\"{context.Error}\""
                        : $"Bearer error=\"{context.Error}\", error_description=\"{context.ErrorDescription}\"";

                var problems = context.HttpContext.RequestServices.GetRequiredService<IProblemDetailsService>();
                await problems.WriteAsync(new ProblemDetailsContext
                {
                    HttpContext = context.HttpContext,
                    ProblemDetails =
                    {
                        Status = StatusCodes.Status401Unauthorized,
                        Title = "Authentication required",
                        Detail = context.AuthenticateFailure is null
                            ? "Send an access token in the Authorization header."
                            : "The access token is invalid or expired.",
                    },
                });
            },
        };
    });
builder.Services.AddAuthorization();

var app = builder.Build();
app.MapGet("/orders/mine", () => TypedResults.Ok(Array.Empty<object>())).RequireAuthorization();
app.Run();

Pitfall: an ID token accepted as an access token

A single-page app that sends its ID token to the API gets a 401 for the wrong audience, and a developer adds the SPA's client id to <code>ValidAudiences</code> to make it work. Now any ID token for that client, which is meant for the SPA and may be logged by it, works as an API credential. Keep the audience to the API's identifier, ask the provider for a real access token with the API's scope, and pin <code>ValidTypes</code> when the provider sets <code>typ</code>.

Example · csharp
using Microsoft.AspNetCore.Authentication.JwtBearer;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"];

        // Wrong: an ID token's audience is the client id, so this "fix" lets ID tokens in.
        // options.TokenValidationParameters.ValidAudiences = ["orders-api", "shop-web"];

        // Right: only the API's own audience, and only RFC 9068 access tokens
        // when the provider marks them with typ "at+jwt" (Duende IdentityServer does by default).
        options.TokenValidationParameters.ValidAudiences = ["orders-api"];
        options.TokenValidationParameters.ValidTypes = ["at+jwt"];
    });
builder.Services.AddAuthorization();

var app = builder.Build();
app.MapGet("/orders/mine", () => TypedResults.Ok(Array.Empty<object>())).RequireAuthorization();
app.Run();

Discussion

  • Be the first to comment on this lesson.