Skip to content
KB

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, and nbf if present) - the token hasn’t expired or isn’t yet valid.

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:

examples/dotnet/validate-entra-id-access-tokens.cs
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
examples/dotnet/validate-entra-id-access-tokens.cs
#: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:

examples/dotnet/validate-entra-id-access-tokens.cs
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
examples/dotnet/validate-entra-id-access-tokens.cs
#: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.

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 --> J

The 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.

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.

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.

What is the right way to authenticate a .NET desktop client against an API that issues JWTs? (dotnet, howto)What is the right way toauthenticate a .NET desktop…How do I add a second JWT bearer authentication scheme to an ASP.NET Core API without it conflicting with the existing one? (identity, howto)How do I add a second JWT bearerauthentication scheme to an…How do I add Microsoft Entra ID sign-in to a web app or single-page app? (identity, howto)How do I add Microsoft Entra IDsign-in to a web app or…What is single sign-on and how do OAuth 2.0 and OpenID Connect implement it with Microsoft Entra ID? (identity, howto)What is single sign-on and howdo OAuth 2.0 and OpenID Connect…What database schema links an Entra ID (or other SSO) identity to an existing local user table? (identity, howto)What database schema links anEntra ID (or other SSO)…How do I coordinate Microsoft Entra ID sign-in between a single-page app and a separately hosted backend API? (identity, howto)How do I coordinate MicrosoftEntra ID sign-in between a…
Open in the graph

Source: https://learn.microsoft.com/en-us/aspnet/core/security/authentication/configure-jwt-bearer-authentication

History: 4 changes
  1. Example changed
  2. Body changed; example changed
  3. Made public
  4. Created