← Back to ArticlesAPI Management

APIM — JWT Validation and OAuth2 Token Validation Policies

Implementing robust JWT validation, token verification, claims extraction, and authorization with Azure API Management policies.

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"
}

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:

Signature Verification

The signature ensures the token hasn't been tampered with. For RS256 (the most common algorithm with Azure AD):

  1. APIM fetches the public keys from the JWKS (JSON Web Key Set) endpoint
  2. It uses the kid from the header to find the correct key
  3. It verifies the signature over base64url(header) + "." + base64url(payload)
  4. 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:

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:

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 MessageCauseFix
JWT not presentNo Authorization header in requestEnsure client sends Authorization: Bearer <token>
Invalid tokenToken signature verification failedCheck that the JWKS endpoint is correct and accessible
Token expiredThe exp claim is in the pastClient needs to refresh the token before calling
Audience not foundToken's aud doesn't match configured audiencesVerify the <audience> value matches the token's aud claim
Issuer not foundToken's iss doesn't match configured issuersCheck <issuer> matches exactly (trailing slashes matter!)
Required claim missingToken doesn't contain a required claimEnsure the app registration includes the required claims
Claim value mismatchClaim exists but value doesn't matchCheck role assignments in Azure AD app registration
Unable to download JWKSAPIM can't reach the OpenID config URLCheck NSG/firewall rules; APIM needs outbound HTTPS access
Key not foundToken's kid doesn't match any key in JWKSKey rotation may have occurred; keys are cached ~24h

Debugging Tips

  1. Decode the token — Use jwt.ms to inspect the token's claims without verification
  2. Check APIM trace — Enable tracing in the test console to see exactly where validation fails
  3. Verify the OpenID config URL — Open it in a browser to confirm it returns valid JSON
  4. Watch for v1 vs v2 endpoints — Azure AD v1 (/oauth2/) and v2 (/oauth2/v2.0/) have different issuer formats
  5. Trailing slashes — Some issuers include a trailing slash, some don't. They must match exactly.

Best Practices

PracticeRecommendation
Token locationAlways use header-name="Authorization" (not query params)
ExpirationAlways set require-expiration-time="true"
Signed tokensAlways set require-signed-tokens="true"
Audience validationAlways specify at least one <audience>
Issuer validationAlways specify allowed <issuer> values
Error messagesUse custom error responses; don't leak internal details
Key rotationUse openid-config URL (auto-discovers keys) instead of hardcoded keys
Multiple IdPsUse multiple <openid-config> elements in one policy
Backend headersRemove the original Authorization header after validation
Rate limitingUse rate-limit-by-key with the sub claim for per-user limits
Scopes vs RolesUse roles for app permissions, scp for delegated permissions
Clock skewAPIM allows 5 minutes of clock skew by default; don't override unless necessary
Policy scopeApply 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:

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.