By AndyPublished
Azure AD JWT Token Validation: Validating Microsoft Entra ID Access Tokens
https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration. From it, the API verifies the RS256 signature against the key in jwks_uri that matches the token's kid. It then checks that iss matches the tenant (https://login.microsoftonline.com/{tid}/v2.0 for v2,https://sts.windows.net/{tid}/ for v1), that aud is your API, and that exp and nbf hold. Finally it authorises on scp (delegated) or roles (app roles). Only validate tokens issued for your own API; Microsoft Graph tokens are not meant to be validated by you. This guide follows Microsoft's identity platform documentation on learn.microsoft.com as of September 2026.Which Azure Tokens Should You Validate?
Microsoft is specific about this. Web APIs must validate the access tokens sent to them, and must only accept tokens whose aud is one of their own identifiers. Client apps (SPAs, mobile and desktop apps) should treat access tokens as opaque strings and not validate them at all.
api://{your-api-client-id}/scope-name.Microsoft's documentation on access token claims and validation applies only to tokens for registered APIs, not to tokens for Microsoft-owned APIs. Tokens that a Microsoft API receives might not be a decodable JWT at all.
Azure JWT Token Example (v2.0 Access Token)
This is the shape of Microsoft's published v2.0 sample, with identifiers replaced by placeholders:
// header
{ "typ": "JWT", "alg": "RS256", "kid": "H4iJ5kL6mN7oP8qR9sT0uV1wX2yZ3a" }
// payload
{
"aud": "00001111-aaaa-2222-bbbb-3333cccc4444",
"iss": "https://login.microsoftonline.com/aaaabbbb-0000-cccc-1111-dddd2222eeee/v2.0",
"iat": 1537231048,
"nbf": 1537231048,
"exp": 1537234948,
"aio": "AXQAi/8IAAAA...",
"azp": "11112222-bbbb-3333-cccc-4444dddd5555",
"azpacr": "0",
"name": "Abe Lincoln",
"oid": "690222be-ff1a-4d56-abd1-7e4f7d38e474",
"preferred_username": "abeli@contoso.com",
"rh": "I",
"scp": "access_as_user",
"sub": "HKZpfaHyWadeOouYlitjrI-KffTm222X5rrV3xDqfKQ",
"tid": "aaaabbbb-0000-cccc-1111-dddd2222eeee",
"uti": "fqiBqXLPj0eQa82S-IYFAA",
"ver": "2.0"
}Azure AD v1 vs v2 Tokens
The access token version is chosen by the API's app registration, not by the endpoint the client calls. The setting is requestedAccessTokenVersion in Microsoft Graph and the current manifest; older manifests and many blog posts call it accessTokenAcceptedVersion. Values of null or 1 produce v1.0 tokens, and2 produces v2.0 tokens. It must be 2 if the app accepts personal Microsoft accounts. A client using the v2.0 endpoint can therefore still receive a v1.0 access token, which is why so many APIs are surprised to see sts.windows.net issuers.
| Item | v1.0 token | v2.0 token |
|---|---|---|
| ver claim | "1.0" | "2.0" |
| iss | https://sts.windows.net/{tenant-id}/ | https://login.microsoftonline.com/{tenant-id}/v2.0 |
| aud | Client ID or the App ID URI (e.g. api://...), depending on the request | Always the API's client ID (GUID) |
| Client application | appid, appidacr | azp, azpacr |
| Username | unique_name, upn | preferred_username |
| Header key IDs | kid and x5t | kid only |
| OIDC metadata | login.microsoftonline.com/{tenant}/.well-known/openid-configuration | login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration |
Microsoft's rule is to validate each version against its own metadata document. A v1.0 token (ver = "1.0") uses the metadata URL without /v2.0, even if your API is configured with a v2.0 authority, and vice versa. The simplest fix is usually to set the API to v2 and accept one format.
Azure AD JWT Claims That Matter for Validation
- ·aud: must be your API. For v2 tokens this is always the API's client ID. For v1 tokens it can be the client ID or the App ID URI the client requested (such as
api://...), so v1 APIs often need both in their valid audiences. See the audience claim. - ·iss and tid: the issuer embeds the tenant ID, and
tidrepeats it. The personal Microsoft account tenant is9188040d-6c67-4c5b-b112-36a304b66dad. - ·scp: a space-separated string of delegated scopes, present only when a user is involved.
- ·roles: an array of app roles. For user tokens these are the user's assigned roles on your app. For client-credentials (app-only) tokens they are the application permissions, and there is no
scp. - ·azp / appid: the client application that requested the token. You can use it to allow-list callers.
- ·groups, wids: group object IDs and directory role template IDs, if configured. Above 200 groups in a JWT, Entra ID drops
groupsand emits an overage claim (_claim_names/_claim_sources) that points you to Graph. - ·name, preferred_username: mutable display values. Microsoft says not to use them for authorisation.
- ·aio, rh, uti: internal or token-ID claims. Ignore the first two;
utiplays the role ofjti.
Microsoft also warns that claims appear only when they have a value and that new claims may be added, so do not fail a token because an optional claim is missing. For the general meaning of each registered claim, see JWT claims explained.
JWT sub vs oid in Entra ID
Both identify the user, but at different scopes:
- ·sub is a pairwise identifier: unique to the user and the application. The same person signing in to two different client IDs gets two different
subvalues. - ·oid is the user's (or service principal's) object ID in the tenant. It is the same across every application in that tenant, and it is the
idMicrosoft Graph returns for the user.
Use oid together with tid as the database key when several of your apps or services need to agree on who a user is. Use sub when you want to limit correlation. Either way, include the tenant: the same user in two tenants has two different object IDs, and Microsoft notes that claims are interpreted within the issuer and tenant.
Azure JWT Token Validation, Step by Step
- ·1. Load metadata. Single tenant:
https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration. Multi-tenant:/organizations/v2.0/...or/common/v2.0/.... - ·2. Verify the signature with the key from
jwks_uriwhosekidmatches the header, and allow only RS256. Keys rotate, so refresh them; Microsoft suggests checking about every 24 hours. Apps using claims-mapping with custom signing keys must add?appid={client-id}to the metadata URL. - ·3. Validate the issuer. Single tenant: exact match with the metadata
issuer. Multi-tenant: the metadata issuer is the templatehttps://login.microsoftonline.com/{tenantid}/v2.0. Substitute the token'stid, require an exact match, confirmtidis a GUID, and check the signing key's ownissuerproperty in the keys document. Then restrict to the tenants you actually serve. - ·4. Validate aud against your client ID (and App ID URI for v1).
- ·5. Check exp and nbf with a small leeway.
- ·6. Authorise on
scpfor delegated calls orrolesfor app-only calls. See scopes vs roles.
Multi-tenant pitfalls
The most dangerous shortcut in multi-tenant APIs is turning issuer validation off because the issuer "changes per tenant". With issuer validation off and the common keys, any Entra tenant in the world can mint a token for an app registration it controls, and only the audience check stands in its way. Follow Microsoft's substitution rule instead: build the expected issuer from the token'stid, require an exact match, and then check tid against the tenants you have onboarded. Also include tid in every data lookup, because Microsoft notes that the same sub in two tenants describes two different users.
ASP.NET Core: Microsoft.Identity.Web
On ASP.NET Core, Microsoft recommends Microsoft.Identity.Web, which applies these rules for you:
// appsettings.json: "AzureAd": { "Instance": "https://login.microsoftonline.com/",
// "TenantId": "<tenant-id>", "ClientId": "<api-client-id>" }
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));Plain AddJwtBearer also works for a single tenant; the ASP.NET Core JWT guide covers the claim-mapping settings you need for roles to drive [Authorize(Roles = ...)].
Node.js with jose
import { createRemoteJWKSet, jwtVerify } from "jose";
const TENANT_ID = "aaaabbbb-0000-cccc-1111-dddd2222eeee";
const API_CLIENT_ID = "00001111-aaaa-2222-bbbb-3333cccc4444";
// jwks_uri from the v2.0 openid-configuration document for the tenant
const JWKS = createRemoteJWKSet(
new URL(`https://login.microsoftonline.com/${TENANT_ID}/discovery/v2.0/keys`)
);
export async function validateEntraAccessToken(token) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: `https://login.microsoftonline.com/${TENANT_ID}/v2.0`,
audience: API_CLIENT_ID,
algorithms: ["RS256"],
clockTolerance: 60,
requiredClaims: ["tid", "oid"],
});
if (payload.ver !== "2.0") throw new Error("Expected a v2.0 access token");
if (payload.tid !== TENANT_ID) throw new Error("Wrong tenant");
const scopes = typeof payload.scp === "string" ? payload.scp.split(" ") : [];
const roles = Array.isArray(payload.roles) ? payload.roles : [];
if (!scopes.includes("Orders.Read") && !roles.includes("Orders.Read.All")) {
throw new Error("Missing Orders.Read permission");
}
return payload;
}In production, read jwks_uri from the metadata document rather than hard-coding it. The example was run against a locally signed test token with the same claim layout. For the mechanics of key sets and kid lookups, see what is a JWKS.
JWT Validation in Azure API Management (APIM)
APIM can reject bad tokens at the gateway. For Entra ID tokens, Microsoft points to the dedicatedvalidate-azure-ad-token policy; the generic validate-jwtpolicy is intended for other identity providers but also accepts Entra metadata. Both are available in all APIM tiers and run in the inbound section.
<!-- Entra ID: dedicated policy -->
<validate-azure-ad-token tenant-id="{{aad-tenant-id}}" output-token-variable-name="jwt">
<client-application-ids>
<application-id>{{aad-client-application-id}}</application-id>
</client-application-ids>
<audiences>
<audience>00001111-aaaa-2222-bbbb-3333cccc4444</audience>
</audiences>
</validate-azure-ad-token>
<!-- Generic policy with Entra metadata -->
<validate-jwt header-name="Authorization" require-scheme="Bearer"
failed-validation-httpcode="401" clock-skew="60">
<openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>00001111-aaaa-2222-bbbb-3333cccc4444</audience>
</audiences>
</validate-jwt>- ·
validate-jwtdefaults to aclock-skewof 0 seconds and requiresexpunlessrequire-expiration-time="false". Its supported asymmetric algorithms are PS256, RS256, RS512 and ES256. - ·APIM re-fetches OpenID configuration hourly, and at most every five minutes when it meets an unknown
kid. - ·Both policies accept a
required-claimsblock (withmatch="all"or"any") for simple claim-value checks at the gateway. - ·Gateway validation does not replace validation in the API itself if the API can be reached without going through APIM.
Azure JWT Decode and Troubleshooting
Decoding first answers most questions: ver, iss andaud show at once whether you have a v1 or v2 token, and whether it was issued for your API or for Graph. Microsoft's docs mention jwt.ms for this. The jwtdecode.app decoder also works and decodes in your browser without sending the token anywhere. Then match the error:
| Error (Microsoft.IdentityModel) | Usual meaning |
|---|---|
| IDX10214: Audience validation failed | aud is the client ID but you configured the App ID URI (or the reverse), or the token is for another API such as Graph |
| IDX10205: Issuer validation failed | v1 token (sts.windows.net) checked against a v2 issuer, or a multi-tenant app expecting one tenant |
| IDX10503 / IDX10511: Signature validation failed | Keys fetched from the wrong metadata document, a Graph token, or a token from another cloud or tenant |
| IDX10223: Lifetime validation failed. The token is expired | Expired token or clock drift; clients should refresh silently through MSAL |
More general failure patterns are in common JWT errors and fixesand the JWT debugging routine.
Summary
Azure AD JWT token validation means validating only tokens for your own API (never Microsoft Graph tokens) against the metadata document for the token's version. Check the RS256 signature viajwks_uri and kid, then the issuer (sts.windows.net for v1, login.microsoftonline.com/{tid}/v2.0 for v2, with tid substitution for multi-tenant apps), aud, and the lifetime. Authorise on scp or roles, and key users onoid plus tid; sub is per-application. Set requestedAccessTokenVersion to 2 to keep to one format, and usevalidate-azure-ad-token when APIM sits in front of the API.