How do I validate Microsoft Entra ID access tokens on the server to protect an API?
howto · dotnet · aspnet-core · security · authentication · entra-id · jwt · oauth
Register JWT bearer auth with the tenant's Authority and Audience (`AddMicrosoftIdentityWebApi`, or `AddJwtBearer`): it fetches Entra's keys and checks signature, issuer, audience and expiry before any handler runs. A valid signature only proves Entra ID issued the token, not that it targets this API - check the audience, and require a scope or app role with `[Authorize]` plus a permission check. Never decode a bearer token by hand and trust its claims: an unvalidated token is only as trustworthy as a header the caller typed in.
Why the API has to validate the token itself
Section titled “Why the API has to validate the token itself”A bearer token is just a string; anything presenting the right one gets treated as whoever it names, so an
API that trusts an Authorization header without checking it is trusting the caller’s word. Microsoft Entra
ID access tokens are JWTs signed with an asymmetric key Entra rotates on its own schedule, published at a
per-tenant OpenID Connect discovery document
(https://login.microsoftonline.com/<tenant>/v2.0/.well-known/openid-configuration), which in turn points at
a JSON Web Key Set (JWKS) of the current public keys. Validating a token server-side means checking four
things against that document before a single claim is read for authorization:
- Signature - the token was signed with a key Entra currently publishes for this tenant, so it wasn’t forged or altered in transit.
- Issuer (
iss) - the token came from the tenant this API actually trusts, not some other Entra tenant (Entra’s signing infrastructure is shared across all tenants, so a valid signature alone doesn’t imply the right issuer - see the trap below). - Audience (
aud) - the token was minted for this API (its Application ID URI or client ID), not for a different API the same user happens to also have a token for. - Expiry (
exp, andnbfif present) - the token hasn’t expired or isn’t yet valid.
Wire it up with Microsoft.Identity.Web
Section titled “Wire it up with Microsoft.Identity.Web”For an ASP.NET Core API registered with Entra ID, Microsoft.Identity.Web (dotnet add package Microsoft.Identity.Web) wraps Microsoft.AspNetCore.Authentication.JwtBearer and handles all four checks,
including the per-tenant issuer quirk below, from a few configuration values:
AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")),
reading an AzureAd configuration section shaped { "Instance": "https://login.microsoftonline.com/", "TenantId": "<tenant-guid-or-domain>", "ClientId": "<this-api's-application-id>" }.
AddMicrosoftIdentityWebApi builds the Authority from Instance + TenantId, fetches the discovery
document and JWKS itself (and refreshes them as Entra rotates keys, so a key rollover never needs a
deployment), and defaults Audience to ClientId - the app registration’s Application ID, or its Application
ID URI (api://<client-id>) when the token was requested against that URI instead. [Authorize] alone only
proves the token passed those four checks; it says nothing about what the caller may do, which is the next
section.
Without Microsoft.Identity.Web: AddJwtBearer directly
Section titled “Without Microsoft.Identity.Web: AddJwtBearer directly”The same four checks without the wrapper - useful for a non-Microsoft stack sharing this API’s auth code, or
to see what the wrapper is doing - are AddJwtBearer with a TokenValidationParameters that names the
tenant’s issuer, this API’s audience and the key that signed the token; a real registration points Authority
at the tenant’s discovery document instead of the local, hand-rolled key below, which is what lets it fetch
and rotate that key on its own:
var builder = WebApplication.CreateBuilder();builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { // Without this, ASP.NET Core silently renames "scp" and "roles" to // legacy, long-form claim URIs before the policy below ever sees // them, and a literal HasClaim("scp", ...) never matches. options.MapInboundClaims = false; options.TokenValidationParameters = new() { ValidateIssuerSigningKey = true, IssuerSigningKey = tenantKey, // stands in for the tenant's JWKS ValidateIssuer = true, ValidIssuer = issuer, // the tenant's own GUID, never "common" ValidateAudience = true, ValidAudience = Audience, ValidateLifetime = true, }; });compiled and run by the build
Show the whole file
#:sdk Microsoft.NET.Sdk.Web#:package Microsoft.AspNetCore.Authentication.JwtBearer@10.0.12#:package Microsoft.AspNetCore.TestHost@10.0.12#:package Microsoft.IdentityModel.JsonWebTokens@8.23.0#:package Microsoft.IdentityModel.Tokens@8.23.0
// The four checks AddJwtBearer runs before a handler sees the request, and// the scope/role check after it: proved against an in-memory TestServer, no// socket, no real Entra tenant. A local RSA key stands in for the current// key Entra's discovery document and JWKS would publish for the tenant - the// signature check is exactly the same once a key is in hand, and standing// one in locally is what lets every claim below run without network access.using System.Net;using Microsoft.AspNetCore.Authentication.JwtBearer;using Microsoft.AspNetCore.Authorization;using Microsoft.AspNetCore.TestHost;using Microsoft.IdentityModel.JsonWebTokens;using Microsoft.IdentityModel.Tokens;
var failures = 0;void Check(bool ok, string what){ if (!ok) { Console.Error.WriteLine($"FAILED: {what}"); failures++; }}
const string TenantId = "3f6a1c9e-7b2d-4e5a-9c3f-1a2b3c4d5e6f";var issuer = $"https://login.microsoftonline.com/{TenantId}/v2.0";const string Audience = "api://widgets-api";
using var tenantRsa = System.Security.Cryptography.RSA.Create(2048);var tenantKey = new RsaSecurityKey(tenantRsa) { KeyId = "tenant-key" };// A key Entra never published for this tenant: same shape token, wrong signature.using var otherRsa = System.Security.Cryptography.RSA.Create(2048);var otherKey = new RsaSecurityKey(otherRsa) { KeyId = "other-key" };
string MakeToken(SecurityKey key, string tokenIssuer, string audience, DateTime expires, IDictionary<string, object> claims){ var handler = new JsonWebTokenHandler(); var descriptor = new SecurityTokenDescriptor { Issuer = tokenIssuer, Audience = audience, Expires = expires, SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256), Claims = claims, }; return handler.CreateToken(descriptor);}
var builder = WebApplication.CreateBuilder();builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { // Without this, ASP.NET Core silently renames "scp" and "roles" to // legacy, long-form claim URIs before the policy below ever sees // them, and a literal HasClaim("scp", ...) never matches. options.MapInboundClaims = false; options.TokenValidationParameters = new() { ValidateIssuerSigningKey = true, IssuerSigningKey = tenantKey, // stands in for the tenant's JWKS ValidateIssuer = true, ValidIssuer = issuer, // the tenant's own GUID, never "common" ValidateAudience = true, ValidAudience = Audience, ValidateLifetime = true, }; });builder.Services.AddAuthorization(options => options.AddPolicy("Widgets.Read", policy => policy.RequireAssertion(ctx => ctx.User.HasClaim("scp", "Widgets.Read") || ctx.User.HasClaim("roles", "Widgets.Read.All"))));
builder.WebHost.UseTestServer();var app = builder.Build();app.UseAuthentication();app.UseAuthorization();app.MapGet("/widgets", () => Results.Ok(Array.Empty<string>())).RequireAuthorization("Widgets.Read");await app.StartAsync();
var client = app.GetTestClient();async Task<HttpResponseMessage> Call(string? token){ var request = new HttpRequestMessage(HttpMethod.Get, "/widgets"); if (token is not null) { request.Headers.Authorization = new("Bearer", token); } return await client.SendAsync(request);}
var noHeader = await Call(null);Check(noHeader.StatusCode == HttpStatusCode.Unauthorized, "no Authorization header at all: 401");Check(noHeader.Headers.WwwAuthenticate.Any(), "no Authorization header at all: WWW-Authenticate is set");
var now = DateTime.UtcNow;var oneScope = new Dictionary<string, object> { ["scp"] = "Widgets.Read" };var badSignature = MakeToken(otherKey, issuer, Audience, now.AddHours(1), oneScope);var wrongIssuer = MakeToken(tenantKey, "https://login.microsoftonline.com/common/v2.0", Audience, now.AddHours(1), oneScope);var wrongAudience = MakeToken(tenantKey, issuer, "api://a-different-api", now.AddHours(1), oneScope);var expired = MakeToken(tenantKey, issuer, Audience, now.AddHours(-1), oneScope);var missingScope = MakeToken(tenantKey, issuer, Audience, now.AddHours(1), new Dictionary<string, object> { ["scp"] = "Widgets.Write" });var delegatedWithScope = MakeToken(tenantKey, issuer, Audience, now.AddHours(1), oneScope);var applicationWithRole = MakeToken(tenantKey, issuer, Audience, now.AddHours(1), new Dictionary<string, object> { ["roles"] = new[] { "Widgets.Read.All" } });
Check((await Call(badSignature)).StatusCode == HttpStatusCode.Unauthorized, "signed with a key Entra never published for this tenant: 401");Check((await Call(wrongIssuer)).StatusCode == HttpStatusCode.Unauthorized, "iss is the literal \"common\", not this tenant's GUID: 401");Check((await Call(wrongAudience)).StatusCode == HttpStatusCode.Unauthorized, "aud names a different API: 401");Check((await Call(expired)).StatusCode == HttpStatusCode.Unauthorized, "exp is in the past: 401");Check((await Call(missingScope)).StatusCode == HttpStatusCode.Forbidden, "passes all four checks but scp lacks the required scope: 403, not 401");Check((await Call(delegatedWithScope)).StatusCode == HttpStatusCode.OK, "a delegated token whose scp carries the required scope: 200");Check((await Call(applicationWithRole)).StatusCode == HttpStatusCode.OK, "an application token whose roles carries the required app role: 200");
await app.StopAsync();
Console.WriteLine(failures == 0 ? "validate-entra-id-access-tokens (csharp): all claims hold" : $"{failures} FAILED");return failures == 0 ? 0 : 1;Setting Authority (in place of the stand-in key above) and Audience already implies
ValidateIssuer/ValidateAudience/ValidateIssuerSigningKey = true and populates
ValidIssuer/ValidAudience from them, so writing out TokenValidationParameters against a real Authority
changes nothing by default; do it only when a specific parameter needs to differ (multiple valid audiences, a
non-standard metadata address behind a proxy), not as boilerplate on every registration. The four checks
below run against the stand-in key with no network access at all, which is exactly why a real deployment
delegates the key itself to Authority instead of pinning one in code.
Checking permissions, not just identity: scopes and app roles
Section titled “Checking permissions, not just identity: scopes and app roles”A token that passes all four checks still needs a permission check, because Entra issues two different kinds
of access token against the same API: a delegated token (a user signed in, carrying a scp claim listing
the OAuth scopes they consented to) and an application token (a client credentials grant with no user,
carrying a roles claim listing the app roles granted to that client). Microsoft.Identity.Web.Resource’s
RequiredScopeOrAppPermission attribute checks either - [RequiredScopeOrAppPermission( RequiredScopesConfigurationKey = "AzureAd:Scopes:Read", RequiredAppPermissionsConfigurationKey = "AzureAd:AppPermissions:Read")] on the action - reading the accepted scope and role names from configuration
rather than hardcoding them, so granting a new scope is a config change, not a redeploy. Without a wrapper,
the same check reads the claim directly, behind an ASP.NET Core authorization policy:
builder.Services.AddAuthorization(options => options.AddPolicy("Widgets.Read", policy => policy.RequireAssertion(ctx => ctx.User.HasClaim("scp", "Widgets.Read") || ctx.User.HasClaim("roles", "Widgets.Read.All"))));compiled and run by the build
Show the whole file
#:sdk Microsoft.NET.Sdk.Web#:package Microsoft.AspNetCore.Authentication.JwtBearer@10.0.12#:package Microsoft.AspNetCore.TestHost@10.0.12#:package Microsoft.IdentityModel.JsonWebTokens@8.23.0#:package Microsoft.IdentityModel.Tokens@8.23.0
// The four checks AddJwtBearer runs before a handler sees the request, and// the scope/role check after it: proved against an in-memory TestServer, no// socket, no real Entra tenant. A local RSA key stands in for the current// key Entra's discovery document and JWKS would publish for the tenant - the// signature check is exactly the same once a key is in hand, and standing// one in locally is what lets every claim below run without network access.using System.Net;using Microsoft.AspNetCore.Authentication.JwtBearer;using Microsoft.AspNetCore.Authorization;using Microsoft.AspNetCore.TestHost;using Microsoft.IdentityModel.JsonWebTokens;using Microsoft.IdentityModel.Tokens;
var failures = 0;void Check(bool ok, string what){ if (!ok) { Console.Error.WriteLine($"FAILED: {what}"); failures++; }}
const string TenantId = "3f6a1c9e-7b2d-4e5a-9c3f-1a2b3c4d5e6f";var issuer = $"https://login.microsoftonline.com/{TenantId}/v2.0";const string Audience = "api://widgets-api";
using var tenantRsa = System.Security.Cryptography.RSA.Create(2048);var tenantKey = new RsaSecurityKey(tenantRsa) { KeyId = "tenant-key" };// A key Entra never published for this tenant: same shape token, wrong signature.using var otherRsa = System.Security.Cryptography.RSA.Create(2048);var otherKey = new RsaSecurityKey(otherRsa) { KeyId = "other-key" };
string MakeToken(SecurityKey key, string tokenIssuer, string audience, DateTime expires, IDictionary<string, object> claims){ var handler = new JsonWebTokenHandler(); var descriptor = new SecurityTokenDescriptor { Issuer = tokenIssuer, Audience = audience, Expires = expires, SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256), Claims = claims, }; return handler.CreateToken(descriptor);}
var builder = WebApplication.CreateBuilder();builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { // Without this, ASP.NET Core silently renames "scp" and "roles" to // legacy, long-form claim URIs before the policy below ever sees // them, and a literal HasClaim("scp", ...) never matches. options.MapInboundClaims = false; options.TokenValidationParameters = new() { ValidateIssuerSigningKey = true, IssuerSigningKey = tenantKey, // stands in for the tenant's JWKS ValidateIssuer = true, ValidIssuer = issuer, // the tenant's own GUID, never "common" ValidateAudience = true, ValidAudience = Audience, ValidateLifetime = true, }; });builder.Services.AddAuthorization(options => options.AddPolicy("Widgets.Read", policy => policy.RequireAssertion(ctx => ctx.User.HasClaim("scp", "Widgets.Read") || ctx.User.HasClaim("roles", "Widgets.Read.All"))));
builder.WebHost.UseTestServer();var app = builder.Build();app.UseAuthentication();app.UseAuthorization();app.MapGet("/widgets", () => Results.Ok(Array.Empty<string>())).RequireAuthorization("Widgets.Read");await app.StartAsync();
var client = app.GetTestClient();async Task<HttpResponseMessage> Call(string? token){ var request = new HttpRequestMessage(HttpMethod.Get, "/widgets"); if (token is not null) { request.Headers.Authorization = new("Bearer", token); } return await client.SendAsync(request);}
var noHeader = await Call(null);Check(noHeader.StatusCode == HttpStatusCode.Unauthorized, "no Authorization header at all: 401");Check(noHeader.Headers.WwwAuthenticate.Any(), "no Authorization header at all: WWW-Authenticate is set");
var now = DateTime.UtcNow;var oneScope = new Dictionary<string, object> { ["scp"] = "Widgets.Read" };var badSignature = MakeToken(otherKey, issuer, Audience, now.AddHours(1), oneScope);var wrongIssuer = MakeToken(tenantKey, "https://login.microsoftonline.com/common/v2.0", Audience, now.AddHours(1), oneScope);var wrongAudience = MakeToken(tenantKey, issuer, "api://a-different-api", now.AddHours(1), oneScope);var expired = MakeToken(tenantKey, issuer, Audience, now.AddHours(-1), oneScope);var missingScope = MakeToken(tenantKey, issuer, Audience, now.AddHours(1), new Dictionary<string, object> { ["scp"] = "Widgets.Write" });var delegatedWithScope = MakeToken(tenantKey, issuer, Audience, now.AddHours(1), oneScope);var applicationWithRole = MakeToken(tenantKey, issuer, Audience, now.AddHours(1), new Dictionary<string, object> { ["roles"] = new[] { "Widgets.Read.All" } });
Check((await Call(badSignature)).StatusCode == HttpStatusCode.Unauthorized, "signed with a key Entra never published for this tenant: 401");Check((await Call(wrongIssuer)).StatusCode == HttpStatusCode.Unauthorized, "iss is the literal \"common\", not this tenant's GUID: 401");Check((await Call(wrongAudience)).StatusCode == HttpStatusCode.Unauthorized, "aud names a different API: 401");Check((await Call(expired)).StatusCode == HttpStatusCode.Unauthorized, "exp is in the past: 401");Check((await Call(missingScope)).StatusCode == HttpStatusCode.Forbidden, "passes all four checks but scp lacks the required scope: 403, not 401");Check((await Call(delegatedWithScope)).StatusCode == HttpStatusCode.OK, "a delegated token whose scp carries the required scope: 200");Check((await Call(applicationWithRole)).StatusCode == HttpStatusCode.OK, "an application token whose roles carries the required app role: 200");
await app.StopAsync();
Console.WriteLine(failures == 0 ? "validate-entra-id-access-tokens (csharp): all claims hold" : $"{failures} FAILED");return failures == 0 ? 0 : 1;A request whose token has neither claim - or the wrong value - should never reach application code that
assumes one particular claim is present. That check only sees a claim literally named scp or roles
because the registration above sets MapInboundClaims = false; left at its default of true,
AddJwtBearer silently renames both to legacy, long-form claim URIs before any policy runs, and a
HasClaim("scp", ...) written against the name Entra actually sends never matches anything.
One request, start to finish
Section titled “One request, start to finish”Strung together, the checks above are one decision tree: every failure short of authorization answers 401,
and only a token that clears all four checks but lacks the right scope or app role answers 403.
flowchart TD
A[Request arrives] --> B{Authorization header?}
B -- no --> R401[401 + WWW-Authenticate]
B -- yes --> C{Signature valid?}
C -- no --> R401
C -- yes --> D{iss is this tenant's GUID?}
D -- no --> R401
D -- yes --> E{aud is this API?}
E -- no --> R401
E -- yes --> F{Not expired?}
F -- no --> R401
F -- yes --> G{Delegated or application?}
G -- delegated --> H{scp has the scope?}
G -- application --> I{roles has the app role?}
H -- no --> R403[403]
I -- no --> R403
H -- yes --> J[Endpoint runs]
I -- yes --> JThe two failure shapes never merge into one: everything left of “Delegated or application?” is the JWT bearer
handler rejecting the token itself, before a single claim is read for what the caller may do, and everything
right of it is authorization rejecting an otherwise-valid caller. A monitoring dashboard that only counts
401s and 403s together loses that distinction along with it.
The trap: a valid signature is not a valid issuer
Section titled “The trap: a valid signature is not a valid issuer”Entra ID’s signing keys are shared across every tenant, so a JWT issued by a completely different
organization’s Entra tenant - or by a different app registration in the same tenant - passes signature
validation: the key really did sign it. What stops that token from being accepted here is the issuer check,
and it has a sharp edge of its own: the iss claim in an Entra v2.0 token is always the tenant’s own GUID
(https://login.microsoftonline.com/<tenant-guid>/v2.0), never the literal common, organizations or
consumers an app registered as multi-tenant uses as its Authority to let any tenant’s users sign in. Point
ValidIssuer (or a hand-rolled AddJwtBearer config) at common verbatim and every token fails issuer
validation outright, because no token’s iss is ever literally common. AddMicrosoftIdentityWebApi and
AddJwtBearer’s Authority both resolve this correctly for a single-tenant app; a multi-tenant API instead
needs a custom IssuerValidator (or ValidIssuers listing every tenant the API actually intends to serve) so
“any tenant can sign in” doesn’t silently become “any tenant’s token is accepted”, which is a data leak
between customers, not a login convenience.
How to verify it worked
Section titled “How to verify it worked”Call the endpoint with no Authorization header: it should answer 401 with a WWW-Authenticate header, not
throw or fall through to application code. A token from a different app registration’s audience, an expired
one, or one edited by hand (even by one character in the signature) should also 401 - the last is the
concrete way to confirm the signature is actually being checked and not merely decoded. jwt.ms
decodes a token’s claims for a manual check of iss/aud/exp without spending it against the API, and the
client credentials request from “Checking permissions” above (POST /{tenant}/oauth2/v2.0/token with the
app registration’s own client ID and secret, scope=api://<client-id>/.default) mints a real, Entra-signed
application token for integration tests without any interactive sign-in. dotnet user-jwts does not fill
that role here: it issues tokens under its own dotnet-user-jwts issuer, signed with a key it generates and
stores locally, which only satisfies a plain AddJwtBearer() with no Authority configured - not the
Entra issuer and JWKS this entry sets up, which will reject a user-jwts token exactly as it should. A
request with a valid token but a missing scope or role should come back 403, distinctly from the 401
cases above - conflating the two hides whether a rejected request failed authentication or authorization.
When this does not apply
Section titled “When this does not apply”A hosting layer that already terminates auth in front of the API - Azure API Management’s validate-jwt
policy, or Azure App Service’s Easy Auth - performs the same four checks before the request reaches the
application, so the app code trusts the platform instead of repeating the checks; wiring AddJwtBearer on top
there is redundant, not wrong, but confirm which layer is actually enforcing it before assuming a header
check downstream is load-bearing. A purely internal service that never receives a token from outside a
private network still benefits from validating it - a compromised host on that network is exactly the case
signature and audience checks are for - so “internal only” narrows the threat model, it doesn’t remove the
need to validate.
History: 4 changes
- Example changed
- Body changed; example changed
- Made public
- Created