APIM — JWT Validation and OAuth2 Token Validation Policies
Azure API Management (APIM) sits at the front door of your APIs. One of its most powerful capabilities is validating JSON Web Tokens (JWTs) before requests ever reach your backend services. This article walks through everything you need to implement robust, production-grade JWT validation at the gateway layer.
Why JWT Validation at the Gateway Matters
In a microservices architecture, every service behind the gateway needs to know who is calling it and whether that caller is authorized. Without centralized validation, each service must independently verify tokens — leading to duplicated logic, inconsistent enforcement, and increased attack surface.
Here's why validating JWTs at the APIM gateway is the right approach:
Centralized Authentication Enforcement A single policy definition protects all your APIs. When you rotate signing keys, update allowed issuers, or add new audience restrictions, you change one place — not dozens of microservices.
Protecting Backends from Unauthenticated Requests Invalid, expired, or malformed tokens are rejected at the edge. Your backend services never see unauthenticated traffic, reducing load and eliminating an entire class of security bugs in downstream code.
Extracting Claims for Downstream Services APIM can pull claims out of the validated token (user ID, roles, tenant, email) and forward them as HTTP headers. Backend services receive pre-validated identity context without needing JWT libraries or access to signing keys.
Reducing Auth Logic Duplication Instead of every microservice implementing token validation, signature verification, and claims parsing, the gateway handles it once. Services trust the forwarded headers from APIM, simplifying their code significantly.
Consistent Error Responses All authentication failures return the same structured error format regardless of which API was called, improving the developer experience for API consumers.
Architecture Overview
Here's how JWT validation flows through Azure API Management:
┌──────────────────────────────────────────────────────────────────────────┐
│ JWT Validation Flow │
└──────────────────────────────────────────────────────────────────────────┘
┌────────┐ ┌──────────────────┐ ┌─────────────────────────┐
│ │ 1. Auth│ │ │ │
│ Client ├────────►│ Azure AD / │ │ │
│ App │◄────────┤ Identity │ │ │
│ │ 2. Token│ Provider │ │ │
└───┬────┘ └──────────────────┘ │ │
│ │ │
│ 3. Request + Bearer Token │ │
│ │ │
▼ │ │
┌──────────────────────────────────────┐ │ │
│ Azure API Management │ │ Backend Services │
│ │ │ │
│ ┌─────────────────────────────────┐ │ │ │
│ │ validate-jwt Policy │ │ │ │
│ │ │ │ │ │
│ │ ✓ Verify signature (JWKS) │ │ │ │
│ │ ✓ Check expiration (exp) │ │ │ │
│ │ ✓ Validate audience (aud) │ │ │ │
│ │ ✓ Validate issuer (iss) │ │ │ │
│ │ ✓ Check required claims │ │ │ │
│ └──────────┬──────────────────────┘ │ │ │
│ │ │ │ │
│ ┌───────┴───────┐ │ │ │
│ │ │ │ │ │
│ Valid? Invalid? │ │ │
│ │ │ │ │ │
│ ▼ ▼ │ │ │
│ ┌───────┐ ┌────────────┐ │ │ │
│ │Extract│ │ Return 401 │ │ │ │
│ │Claims │ │ or 403 │─────────┼───► │ (Client receives │
│ │ │ │ to Client │ │ │ error response) │
│ └──┬────┘ └────────────┘ │ │ │
│ │ │ │ │
│ ▼ │ │ │
│ ┌──────────────────────────┐ │ │ │
│ │ Set Headers: │ │ │ │
│ │ X-User-Id: {sub} │ 4. Forward │ │
│ │ X-User-Email: {email} ├────────┼───────►│ ┌───────────────────┐ │
│ │ X-User-Roles: {roles} │ │ │ │ Backend reads │ │
│ │ X-Tenant-Id: {tid} │ │ │ │ trusted headers │ │
│ └──────────────────────────┘ │ │ └───────────────────┘ │
│ │ │ │
└──────────────────────────────────────┘ └─────────────────────────┘
The key insight: backends never validate tokens themselves. They trust APIM to have done it and simply read the forwarded headers.
Understanding JWT Structure
A JWT consists of three Base64URL-encoded parts separated by dots:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiw...
└──────── Header ────────┘.└──────────── Payload ──────────────┘.└─ Signature ─┘
Header
The header declares the token type and signing algorithm:
{
"alg": "RS256",
"typ": "JWT",
"kid": "abc123"
}
alg— The algorithm used to sign the token (RS256, RS384, ES256, etc.)kid— Key ID, used to look up the correct public key from the JWKS endpoint
Payload (Claims)
The payload contains claims — statements about the user and token:
{
"sub": "user-uuid-here",
"name": "Jane Developer",
"email": "jane@contoso.com",
"aud": "api://my-backend-api",
"iss": "https://login.microsoftonline.com/tenant-id/v2.0",
"iat": 1712678400,
"exp": 1712682000,
"roles": ["API.Read", "API.Write"],
"tid": "tenant-id-guid",
"scp": "user.read profile"
}
Key claims for validation:
iss(issuer) — Who issued the token (must match your identity provider)aud(audience) — Who the token is intended for (must match your API's app ID)exp(expiration) — Unix timestamp when the token expiressub(subject) — Unique identifier for the userroles— Application roles assigned to the userscp(scope) — Delegated permissions granted to the calling app
Signature Verification
The signature ensures the token hasn't been tampered with. For RS256 (the most common algorithm with Azure AD):
- APIM fetches the public keys from the JWKS (JSON Web Key Set) endpoint
- It uses the
kidfrom the header to find the correct key - It verifies the signature over
base64url(header) + "." + base64url(payload) - If verification passes, the token is authentic and unmodified
The JWKS endpoint for Azure AD v2.0:
https://login.microsoftonline.com/{tenant-id}/discovery/v2.0/keys
APIM caches these keys automatically, so it doesn't hit the JWKS endpoint on every request.
Step-by-Step Implementation
Step 1: Basic JWT Validation with Azure AD
The simplest starting point — validate that incoming tokens are issued by your Azure AD tenant for your specific API:
<policies>
<inbound>
<base />
<validate-jwt
header-name="Authorization"
failed-validation-httpcode="401"
failed-validation-error-message="Unauthorized. Valid token required."
require-expiration-time="true"
require-signed-tokens="true">
<openid-config url="https://login.microsoftonline.com/{your-tenant-id}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>api://your-api-client-id</audience>
</audiences>
<issuers>
<issuer>https://login.microsoftonline.com/{your-tenant-id}/v2.0</issuer>
</issuers>
</validate-jwt>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>
What this does:
- Reads the
Authorizationheader and strips theBearerprefix - Downloads the OpenID Connect configuration (which includes the JWKS URL)
- Verifies the token signature using the public keys
- Checks that
exphasn't passed (token not expired) - Validates that
audmatches your API's application ID - Validates that
issmatches your Azure AD tenant - Returns 401 if any check fails
Step 2: Configuring Required Claims
You can require specific claims to be present in the token:
<validate-jwt
header-name="Authorization"
failed-validation-httpcode="401"
failed-validation-error-message="Token missing required claims."
require-expiration-time="true"
require-signed-tokens="true"
output-token-variable-name="validatedToken">
<openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>api://your-api-client-id</audience>
</audiences>
<issuers>
<issuer>https://login.microsoftonline.com/{tenant-id}/v2.0</issuer>
</issuers>
<required-claims>
<claim name="roles" match="any">
<value>API.Access</value>
</claim>
<claim name="email" match="all" />
<claim name="tid" match="all">
<value>{your-tenant-id}</value>
</claim>
</required-claims>
</validate-jwt>
The match attribute controls how claim values are checked:
match="all"— All specified values must be present (AND logic)match="any"— At least one specified value must be present (OR logic)- If no
<value>elements are specified, the claim just needs to exist
Step 3: Extracting Claims into Variables
The output-token-variable-name attribute stores the validated token so you can extract claims:
<policies>
<inbound>
<base />
<!-- Validate and store token -->
<validate-jwt
header-name="Authorization"
output-token-variable-name="jwt"
require-expiration-time="true"
require-signed-tokens="true">
<openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>api://your-api-client-id</audience>
</audiences>
</validate-jwt>
<!-- Extract individual claims into named variables -->
<set-variable name="userId" value="@{
var jwt = (Jwt)context.Variables["jwt"];
return jwt.Claims.GetValueOrDefault("sub", "unknown");
}" />
<set-variable name="userEmail" value="@{
var jwt = (Jwt)context.Variables["jwt"];
return jwt.Claims.GetValueOrDefault("email", "");
}" />
<set-variable name="tenantId" value="@{
var jwt = (Jwt)context.Variables["jwt"];
return jwt.Claims.GetValueOrDefault("tid", "");
}" />
<set-variable name="userRoles" value="@{
var jwt = (Jwt)context.Variables["jwt"];
return jwt.Claims.GetValueOrDefault("roles", "");
}" />
</inbound>
</policies>
Step 4: Setting Backend Headers from JWT Claims
Forward the extracted identity to your backend services as trusted headers:
<policies>
<inbound>
<base />
<validate-jwt header-name="Authorization" output-token-variable-name="jwt">
<openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>api://your-api-client-id</audience>
</audiences>
</validate-jwt>
<!-- Forward user context to backend -->
<set-header name="X-User-Id" exists-action="override">
<value>@{
var jwt = (Jwt)context.Variables["jwt"];
return jwt.Claims.GetValueOrDefault("sub", "");
}</value>
</set-header>
<set-header name="X-User-Email" exists-action="override">
<value>@{
var jwt = (Jwt)context.Variables["jwt"];
return jwt.Claims.GetValueOrDefault("email", "");
}</value>
</set-header>
<set-header name="X-User-Roles" exists-action="override">
<value>@{
var jwt = (Jwt)context.Variables["jwt"];
return jwt.Claims.GetValueOrDefault("roles", "");
}</value>
</set-header>
<set-header name="X-Tenant-Id" exists-action="override">
<value>@{
var jwt = (Jwt)context.Variables["jwt"];
return jwt.Claims.GetValueOrDefault("tid", "");
}</value>
</set-header>
<!-- Remove the original Authorization header so backend doesn't re-validate -->
<set-header name="Authorization" exists-action="delete" />
</inbound>
</policies>
Step 5: Multiple Identity Provider Support
When your API accepts tokens from different identity providers (e.g., Azure AD for employees, Azure AD B2C for customers):
<policies>
<inbound>
<base />
<!-- Try Azure AD (corporate users) first -->
<validate-jwt
header-name="Authorization"
output-token-variable-name="jwt"
failed-validation-httpcode="401"
failed-validation-error-message="Unauthorized">
<openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration" />
<openid-config url="https://{b2c-tenant}.b2clogin.com/{b2c-tenant}.onmicrosoft.com/{policy}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>api://corporate-api-id</audience>
<audience>api://b2c-api-id</audience>
</audiences>
<issuers>
<issuer>https://login.microsoftonline.com/{tenant-id}/v2.0</issuer>
<issuer>https://{b2c-tenant}.b2clogin.com/{b2c-tenant-id}/v2.0/</issuer>
</issuers>
</validate-jwt>
<!-- Determine which IdP issued the token -->
<set-variable name="idpSource" value="@{
var jwt = (Jwt)context.Variables["jwt"];
var issuer = jwt.Claims.GetValueOrDefault("iss", "");
if (issuer.Contains("b2clogin.com")) return "B2C";
return "AzureAD";
}" />
</inbound>
</policies>
When you specify multiple <openid-config> URLs, APIM tries each one until it finds a matching key for the token's signature. This is the cleanest way to support multiple providers without complex conditional logic.
Role-Based Authorization
Once the token is validated, you often need to check whether the user has the right roles or permissions for the specific operation they're attempting.
Checking Roles for Endpoint-Level Authorization
Require specific app roles to access an API:
<policies>
<inbound>
<base />
<validate-jwt header-name="Authorization" output-token-variable-name="jwt">
<openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>api://your-api-client-id</audience>
</audiences>
<required-claims>
<claim name="roles" match="any">
<value>Orders.Read</value>
<value>Orders.ReadWrite</value>
</claim>
</required-claims>
</validate-jwt>
</inbound>
</policies>
This rejects any token that doesn't have at least one of Orders.Read or Orders.ReadWrite in the roles claim.
Different Policies for Different Operations (GET vs POST)
Apply different authorization rules based on the HTTP method:
<policies>
<inbound>
<base />
<!-- Always validate the token -->
<validate-jwt header-name="Authorization" output-token-variable-name="jwt">
<openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>api://your-api-client-id</audience>
</audiences>
</validate-jwt>
<!-- Read operations: require read role -->
<choose>
<when condition="@(context.Request.Method == "GET")">
<set-variable name="hasAccess" value="@{
var jwt = (Jwt)context.Variables["jwt"];
var roles = jwt.Claims.GetValueOrDefault("roles", "");
return roles.Contains("Orders.Read") || roles.Contains("Orders.ReadWrite");
}" />
</when>
<!-- Write operations: require write role -->
<when condition="@(context.Request.Method == "POST" || context.Request.Method == "PUT" || context.Request.Method == "DELETE")">
<set-variable name="hasAccess" value="@{
var jwt = (Jwt)context.Variables["jwt"];
var roles = jwt.Claims.GetValueOrDefault("roles", "");
return roles.Contains("Orders.ReadWrite");
}" />
</when>
<otherwise>
<set-variable name="hasAccess" value="@(false)" />
</otherwise>
</choose>
<!-- Reject if insufficient permissions -->
<choose>
<when condition="@(!((bool)context.Variables["hasAccess"]))">
<return-response>
<set-status code="403" reason="Forbidden" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-body>@{
return new JObject(
new JProperty("error", "insufficient_permissions"),
new JProperty("message", "You do not have the required role for this operation."),
new JProperty("required_role", context.Request.Method == "GET" ? "Orders.Read" : "Orders.ReadWrite")
).ToString();
}</set-body>
</return-response>
</when>
</choose>
</inbound>
</policies>
Scope-Based Authorization (OAuth2 Delegated Permissions)
When using delegated permissions (user signs in via an app), scopes appear in the scp claim instead of roles:
<policies>
<inbound>
<base />
<validate-jwt header-name="Authorization" output-token-variable-name="jwt">
<openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>api://your-api-client-id</audience>
</audiences>
</validate-jwt>
<!-- Check delegated scopes -->
<choose>
<when condition="@{
var jwt = (Jwt)context.Variables["jwt"];
var scopes = jwt.Claims.GetValueOrDefault("scp", "");
var method = context.Request.Method;
if (method == "GET") {
return scopes.Contains("Orders.Read") || scopes.Contains("Orders.ReadWrite");
}
return scopes.Contains("Orders.ReadWrite");
}">
<!-- Access granted, continue -->
</when>
<otherwise>
<return-response>
<set-status code="403" reason="Forbidden" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-body>{"error": "insufficient_scope", "message": "Required OAuth2 scope not present in token."}</set-body>
</return-response>
</otherwise>
</choose>
</inbound>
</policies>
Custom Claims Validation
Validate custom claims added by your identity provider (e.g., subscription tier, department, country):
<policies>
<inbound>
<base />
<validate-jwt header-name="Authorization" output-token-variable-name="jwt">
<openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>api://your-api-client-id</audience>
</audiences>
<!-- Require custom claims -->
<required-claims>
<claim name="subscription_tier" match="any">
<value>premium</value>
<value>enterprise</value>
</claim>
<claim name="department" match="all" />
</required-claims>
</validate-jwt>
<!-- Use custom claims for conditional logic -->
<choose>
<when condition="@{
var jwt = (Jwt)context.Variables["jwt"];
var tier = jwt.Claims.GetValueOrDefault("subscription_tier", "free");
return tier == "enterprise";
}">
<!-- Enterprise users get higher rate limits -->
<rate-limit calls="1000" renewal-period="60" />
</when>
<otherwise>
<!-- Premium users get standard limits -->
<rate-limit calls="100" renewal-period="60" />
</otherwise>
</choose>
</inbound>
</policies>
Advanced Patterns
Token Caching to Reduce Validation Overhead
APIM automatically caches the JWKS keys, but you can also cache the validation result for frequently-seen tokens using the built-in cache:
<policies>
<inbound>
<base />
<!-- Create a cache key from the token -->
<set-variable name="tokenHash" value="@{
var auth = context.Request.Headers.GetValueOrDefault("Authorization", "");
if (auth.StartsWith("Bearer ")) {
var token = auth.Substring(7);
using (var sha = System.Security.Cryptography.SHA256.Create()) {
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(token));
return Convert.ToBase64String(hash);
}
}
return "";
}" />
<!-- Check cache for previously validated token -->
<cache-lookup-value key="@("jwt-valid-" + (string)context.Variables["tokenHash"])" variable-name="cachedUserId" />
<choose>
<when condition="@(!context.Variables.ContainsKey("cachedUserId"))">
<!-- Token not in cache, validate it -->
<validate-jwt header-name="Authorization" output-token-variable-name="jwt">
<openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>api://your-api-client-id</audience>
</audiences>
</validate-jwt>
<!-- Cache the result (cache duration shorter than token expiry) -->
<cache-store-value
key="@("jwt-valid-" + (string)context.Variables["tokenHash"])"
value="@(((Jwt)context.Variables["jwt"]).Claims.GetValueOrDefault("sub", ""))"
duration="300" />
</when>
</choose>
</inbound>
</policies>
Custom Error Responses for Auth Failures
Provide developer-friendly error messages that help API consumers debug authentication issues:
<policies>
<inbound>
<base />
<validate-jwt header-name="Authorization" output-token-variable-name="jwt">
<openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>api://your-api-client-id</audience>
</audiences>
</validate-jwt>
</inbound>
<on-error>
<choose>
<when condition="@(context.LastError.Source == "validate-jwt")">
<return-response>
<set-status code="401" reason="Unauthorized" />
<set-header name="Content-Type" exists-action="override">
<value>application/problem+json</value>
</set-header>
<set-header name="WWW-Authenticate" exists-action="override">
<value>@{
return "Bearer realm=\"your-api\", error=\"invalid_token\", error_description=\"" + context.LastError.Message + "\"";
}</value>
</set-header>
<set-body>@{
return new JObject(
new JProperty("type", "https://tools.ietf.org/html/rfc7235#section-3.1"),
new JProperty("title", "Authentication Failed"),
new JProperty("status", 401),
new JProperty("detail", context.LastError.Message),
new JProperty("instance", context.Request.Url.Path),
new JProperty("timestamp", DateTime.UtcNow.ToString("o"))
).ToString();
}</set-body>
</return-response>
</when>
</choose>
<base />
</on-error>
</policies>
Rate Limiting by User Identity from JWT
Apply per-user rate limits using the subject claim from the validated token:
<policies>
<inbound>
<base />
<validate-jwt header-name="Authorization" output-token-variable-name="jwt">
<openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>api://your-api-client-id</audience>
</audiences>
</validate-jwt>
<!-- Rate limit per user -->
<rate-limit-by-key
calls="100"
renewal-period="60"
counter-key="@{
var jwt = (Jwt)context.Variables["jwt"];
return jwt.Claims.GetValueOrDefault("sub", "anonymous");
}" />
</inbound>
</policies>
Forwarding User Context to Backend Services
A complete pattern for forwarding all relevant user context:
<policies>
<inbound>
<base />
<validate-jwt header-name="Authorization" output-token-variable-name="jwt">
<openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>api://your-api-client-id</audience>
</audiences>
</validate-jwt>
<!-- Build a user context JSON object for the backend -->
<set-header name="X-User-Context" exists-action="override">
<value>@{
var jwt = (Jwt)context.Variables["jwt"];
var userContext = new JObject(
new JProperty("userId", jwt.Claims.GetValueOrDefault("sub", "")),
new JProperty("email", jwt.Claims.GetValueOrDefault("email", "")),
new JProperty("name", jwt.Claims.GetValueOrDefault("name", "")),
new JProperty("tenantId", jwt.Claims.GetValueOrDefault("tid", "")),
new JProperty("roles", jwt.Claims.GetValueOrDefault("roles", "")),
new JProperty("scopes", jwt.Claims.GetValueOrDefault("scp", ""))
);
return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(userContext.ToString()));
}</value>
</set-header>
<!-- Add correlation ID for tracing -->
<set-header name="X-Correlation-Id" exists-action="skip">
<value>@(context.RequestId.ToString())</value>
</set-header>
<!-- Remove original auth header -->
<set-header name="Authorization" exists-action="delete" />
</inbound>
</policies>
Multi-Tenant Validation
Validate that the token's tenant claim matches the expected tenant for the requested resource:
<policies>
<inbound>
<base />
<validate-jwt header-name="Authorization" output-token-variable-name="jwt">
<openid-config url="https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>api://your-multi-tenant-api</audience>
</audiences>
</validate-jwt>
<!-- Extract tenant from URL path (e.g., /api/tenants/{tenantId}/orders) -->
<set-variable name="requestedTenant" value="@(context.Request.MatchedParameters["tenantId"])" />
<!-- Extract tenant from token -->
<set-variable name="tokenTenant" value="@{
var jwt = (Jwt)context.Variables["jwt"];
return jwt.Claims.GetValueOrDefault("tid", "");
}" />
<!-- Ensure token tenant matches requested resource tenant -->
<choose>
<when condition="@((string)context.Variables["requestedTenant"] != (string)context.Variables["tokenTenant"])">
<return-response>
<set-status code="403" reason="Forbidden" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-body>{"error": "tenant_mismatch", "message": "Token tenant does not match the requested resource tenant."}</set-body>
</return-response>
</when>
</choose>
</inbound>
</policies>
Backend Integration
Reading Forwarded Claims in C#
Here's how your ASP.NET Core backend reads the trusted headers set by APIM:
// Middleware to extract APIM-forwarded user context
public class ApimUserContextMiddleware
{
private readonly RequestDelegate _next;
public ApimUserContextMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// Read the Base64-encoded user context from APIM
var userContextHeader = context.Request.Headers["X-User-Context"].FirstOrDefault();
if (!string.IsNullOrEmpty(userContextHeader))
{
var json = Encoding.UTF8.GetString(Convert.FromBase64String(userContextHeader));
var userContext = JsonSerializer.Deserialize<UserContext>(json);
// Store in HttpContext.Items for use in controllers
context.Items["UserContext"] = userContext;
}
await _next(context);
}
}
public record UserContext(
string UserId,
string Email,
string Name,
string TenantId,
string Roles,
string Scopes
);
// Usage in a controller
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
[HttpGet]
public IActionResult GetOrders()
{
var user = HttpContext.Items["UserContext"] as UserContext;
if (user == null)
return Unauthorized();
// Use tenant isolation
var orders = _orderService.GetOrdersByTenant(user.TenantId);
return Ok(orders);
}
}
Correlation Between APIM Auth and Backend Auth
For defense-in-depth, your backend can optionally verify the forwarded identity:
// In Program.cs — trust APIM headers only from known sources
builder.Services.AddAuthentication("ApimForwarded")
.AddScheme<AuthenticationSchemeOptions, ApimForwardedAuthHandler>(
"ApimForwarded", null);
// Custom auth handler that trusts APIM-forwarded headers
public class ApimForwardedAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Only trust headers if request came through APIM (check source IP or shared secret)
var apimSecret = Request.Headers["X-Apim-Secret"].FirstOrDefault();
if (apimSecret != Configuration["Apim:SharedSecret"])
{
return Task.FromResult(AuthenticateResult.Fail("Request did not come through APIM"));
}
var userId = Request.Headers["X-User-Id"].FirstOrDefault();
if (string.IsNullOrEmpty(userId))
{
return Task.FromResult(AuthenticateResult.Fail("No user identity forwarded"));
}
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Email, Request.Headers["X-User-Email"].FirstOrDefault() ?? ""),
new Claim("TenantId", Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "")
};
var identity = new ClaimsIdentity(claims, "ApimForwarded");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "ApimForwarded");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
Testing JWT Validation
Getting Test Tokens via Azure CLI
# Get a token for your API using Azure CLI
az account get-access-token --resource api://your-api-client-id --query accessToken -o tsv
# Get a token for Microsoft Graph (useful for testing issuer validation)
az account get-access-token --resource https://graph.microsoft.com --query accessToken -o tsv
# Decode a token to inspect claims (without verification)
TOKEN=$(az account get-access-token --resource api://your-api-client-id --query accessToken -o tsv)
echo $TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .
Testing with Valid Tokens
# Get a valid token
TOKEN=$(az account get-access-token --resource api://your-api-client-id --query accessToken -o tsv)
# Call your API through APIM
curl -s -w "\nHTTP Status: %{http_code}\n" \
-H "Authorization: Bearer $TOKEN" \
https://your-apim.azure-api.net/api/orders
Testing with Invalid/Expired Tokens
# Test with a completely invalid token
curl -s -w "\nHTTP Status: %{http_code}\n" \
-H "Authorization: Bearer invalid.token.here" \
https://your-apim.azure-api.net/api/orders
# Test with a token for the wrong audience
WRONG_TOKEN=$(az account get-access-token --resource https://graph.microsoft.com --query accessToken -o tsv)
curl -s -w "\nHTTP Status: %{http_code}\n" \
-H "Authorization: Bearer $WRONG_TOKEN" \
https://your-apim.azure-api.net/api/orders
# Test with no token at all
curl -s -w "\nHTTP Status: %{http_code}\n" \
https://your-apim.azure-api.net/api/orders
Testing Role-Based Access
# Use a service principal with specific roles assigned
# First, get a token using client credentials (app-only, roles in token)
TOKEN=$(curl -s -X POST \
"https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token" \
-d "client_id={sp-client-id}" \
-d "client_secret={sp-secret}" \
-d "scope=api://your-api-client-id/.default" \
-d "grant_type=client_credentials" | jq -r '.access_token')
# Test read access (should work with Orders.Read role)
curl -s -H "Authorization: Bearer $TOKEN" \
https://your-apim.azure-api.net/api/orders
# Test write access (needs Orders.ReadWrite role)
curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"item": "test"}' \
https://your-apim.azure-api.net/api/orders
Real-World Scenarios
Multi-Tenant SaaS API with Tenant Isolation
A SaaS platform where each customer (tenant) can only access their own data:
<policies>
<inbound>
<base />
<validate-jwt header-name="Authorization" output-token-variable-name="jwt">
<openid-config url="https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>api://saas-platform-api</audience>
</audiences>
</validate-jwt>
<!-- Enforce tenant isolation: rewrite URL to include tenant -->
<set-variable name="tenantId" value="@(((Jwt)context.Variables["jwt"]).Claims.GetValueOrDefault("tid", ""))" />
<rewrite-uri template="@("/tenants/" + (string)context.Variables["tenantId"] + context.Request.Url.Path)" />
<set-header name="X-Tenant-Id" exists-action="override">
<value>@((string)context.Variables["tenantId"])</value>
</set-header>
</inbound>
</policies>
Microservices with Centralized Auth at Gateway
Multiple backend services behind a single APIM instance, each requiring different roles:
<!-- Applied at the API level (all operations in the Orders API) -->
<policies>
<inbound>
<base />
<validate-jwt header-name="Authorization" output-token-variable-name="jwt">
<openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>api://your-api-client-id</audience>
</audiences>
<required-claims>
<claim name="roles" match="any">
<value>Orders.Access</value>
<value>Admin</value>
</claim>
</required-claims>
</validate-jwt>
<!-- Strip auth, forward identity -->
<set-header name="X-Authenticated-User" exists-action="override">
<value>@(((Jwt)context.Variables["jwt"]).Claims.GetValueOrDefault("sub", ""))</value>
</set-header>
<set-header name="Authorization" exists-action="delete" />
</inbound>
</policies>
B2B API with Partner-Specific Validation
Different partners authenticate with different identity providers and have different rate limits:
<policies>
<inbound>
<base />
<validate-jwt header-name="Authorization" output-token-variable-name="jwt">
<!-- Accept tokens from multiple partner IdPs -->
<openid-config url="https://login.microsoftonline.com/{your-tenant}/v2.0/.well-known/openid-configuration" />
<openid-config url="https://partner-a.auth0.com/.well-known/openid-configuration" />
<openid-config url="https://accounts.google.com/.well-known/openid-configuration" />
<audiences>
<audience>api://b2b-api</audience>
</audiences>
</validate-jwt>
<!-- Identify partner and apply partner-specific rate limits -->
<set-variable name="partnerId" value="@(((Jwt)context.Variables["jwt"]).Claims.GetValueOrDefault("azp", "unknown"))" />
<choose>
<when condition="@((string)context.Variables["partnerId"] == "partner-a-client-id")">
<rate-limit-by-key calls="5000" renewal-period="60" counter-key="partner-a" />
</when>
<when condition="@((string)context.Variables["partnerId"] == "partner-b-client-id")">
<rate-limit-by-key calls="1000" renewal-period="60" counter-key="partner-b" />
</when>
<otherwise>
<rate-limit-by-key calls="100" renewal-period="60" counter-key="@((string)context.Variables["partnerId"])" />
</otherwise>
</choose>
</inbound>
</policies>
Troubleshooting
Common JWT validation errors and how to fix them:
| Error Message | Cause | Fix |
|---|---|---|
JWT not present | No Authorization header in request | Ensure client sends Authorization: Bearer <token> |
Invalid token | Token signature verification failed | Check that the JWKS endpoint is correct and accessible |
Token expired | The exp claim is in the past | Client needs to refresh the token before calling |
Audience not found | Token's aud doesn't match configured audiences | Verify the <audience> value matches the token's aud claim |
Issuer not found | Token's iss doesn't match configured issuers | Check <issuer> matches exactly (trailing slashes matter!) |
Required claim missing | Token doesn't contain a required claim | Ensure the app registration includes the required claims |
Claim value mismatch | Claim exists but value doesn't match | Check role assignments in Azure AD app registration |
Unable to download JWKS | APIM can't reach the OpenID config URL | Check NSG/firewall rules; APIM needs outbound HTTPS access |
Key not found | Token's kid doesn't match any key in JWKS | Key rotation may have occurred; keys are cached ~24h |
Debugging Tips
- Decode the token — Use jwt.ms to inspect the token's claims without verification
- Check APIM trace — Enable tracing in the test console to see exactly where validation fails
- Verify the OpenID config URL — Open it in a browser to confirm it returns valid JSON
- Watch for v1 vs v2 endpoints — Azure AD v1 (
/oauth2/) and v2 (/oauth2/v2.0/) have different issuer formats - Trailing slashes — Some issuers include a trailing slash, some don't. They must match exactly.
Best Practices
| Practice | Recommendation |
|---|---|
| Token location | Always use header-name="Authorization" (not query params) |
| Expiration | Always set require-expiration-time="true" |
| Signed tokens | Always set require-signed-tokens="true" |
| Audience validation | Always specify at least one <audience> |
| Issuer validation | Always specify allowed <issuer> values |
| Error messages | Use custom error responses; don't leak internal details |
| Key rotation | Use openid-config URL (auto-discovers keys) instead of hardcoded keys |
| Multiple IdPs | Use multiple <openid-config> elements in one policy |
| Backend headers | Remove the original Authorization header after validation |
| Rate limiting | Use rate-limit-by-key with the sub claim for per-user limits |
| Scopes vs Roles | Use roles for app permissions, scp for delegated permissions |
| Clock skew | APIM allows 5 minutes of clock skew by default; don't override unless necessary |
| Policy scope | Apply JWT validation at the API level, role checks at the operation level |
Monitoring with KQL Queries
Track authentication patterns and failures using Azure Monitor and Log Analytics:
Authentication Failure Rate
ApiManagementGatewayLogs
| where TimeGenerated > ago(24h)
| where ResponseCode == 401 or ResponseCode == 403
| summarize
FailedRequests = count(),
TotalRequests = count()
by bin(TimeGenerated, 1h), ApiId
| extend FailureRate = round(todouble(FailedRequests) / todouble(TotalRequests) * 100, 2)
| order by TimeGenerated desc
Top Unauthorized Callers
ApiManagementGatewayLogs
| where TimeGenerated > ago(24h)
| where ResponseCode == 401
| summarize AttemptCount = count() by CallerIpAddress, ApiId
| order by AttemptCount desc
| take 20
JWT Validation Errors by Type
ApiManagementGatewayLogs
| where TimeGenerated > ago(7d)
| where ResponseCode == 401
| extend ErrorDetail = tostring(parse_json(ResponseBody).detail)
| summarize Count = count() by ErrorDetail
| order by Count desc
Successful Auth by Tenant (Multi-Tenant Monitoring)
ApiManagementGatewayLogs
| where TimeGenerated > ago(24h)
| where ResponseCode >= 200 and ResponseCode < 300
| extend TenantId = tostring(RequestHeaders["X-Tenant-Id"])
| where isnotempty(TenantId)
| summarize RequestCount = count() by TenantId, bin(TimeGenerated, 1h)
| order by TimeGenerated desc
Alert: Spike in Auth Failures
// Use this as an alert rule — triggers when auth failures exceed threshold
ApiManagementGatewayLogs
| where TimeGenerated > ago(5m)
| where ResponseCode == 401 or ResponseCode == 403
| summarize FailureCount = count()
| where FailureCount > 50
Summary
JWT validation at the API Management gateway is the foundation of a secure API architecture. By centralizing token validation, you:
- Protect backends from ever seeing unauthenticated traffic
- Simplify microservices by removing auth logic from each service
- Enforce consistent policies across all APIs from one place
- Extract and forward identity so backends get pre-validated user context
- Enable fine-grained authorization with role and scope checks at the operation level
Start with basic validation (Step 1), then layer on role-based authorization and claims forwarding as your requirements grow. The patterns in this article scale from a single API to hundreds of microservices behind a shared gateway.