Skip to main content

13 - Customer Q&A: Advanced Architecture Scenarios

Detailed answers to enterprise architecture questions for APIM, Application Gateway, and Front Door

WAF


📋 Table of Contents

#QuestionJump To
1Central logs with use case isolationView
2Distinguishing internal vs external callsView
3Multiple origins behind a domainView
4Central base policy enforcementView
5Schema validation approachView
6Developer Portal & Entra ID benefitsView

1. Central Logs with Use Case Isolation

❓ Question

How can the central logs of the three systems (APIM, Application Gateway, Front Door) be made available to the use cases without the use cases being able to see each other's logs?

🎯 Solution: Log Analytics with RBAC-Based Access

Architecture Options

ApproachIsolation LevelComplexityCost
Option 1: Resource-Context RBACRow-levelLow$
Option 2: Separate WorkspacesCompleteMedium$$
Option 3: Azure Data ExplorerEnterpriseHigh$$$

Use resource-context access mode in Log Analytics - users only see logs from resources they have RBAC permissions on.

Step 1: Enable Resource-Context Mode

resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' = {
name: 'law-central-logs'
location: location
properties: {
sku: {
name: 'PerGB2018'
}
// Enable resource-context access
features: {
enableLogAccessUsingOnlyResourcePermissions: true
}
retentionInDays: 90
}
}

Step 2: Tag Resources by Use Case

// Tag APIM APIs, Products, or use custom dimensions
resource api 'Microsoft.ApiManagement/service/apis@2023-05-01-preview' = {
name: 'usecase-a-api'
parent: apim
properties: {
displayName: 'Use Case A API'
// ...
}
tags: {
UseCase: 'TeamA'
CostCenter: 'CC-001'
}
}

Step 3: Add Use Case Identifier in APIM Logs

<!-- Add custom dimension to all logs for filtering -->
<inbound>
<base />
<set-header name="X-UseCase-Id" exists-action="override">
<value>@(context.Api.Name.StartsWith("uc-a-") ? "TeamA" :
context.Api.Name.StartsWith("uc-b-") ? "TeamB" : "Shared")</value>
</set-header>
<trace source="UseCase" severity="information">
<message>@($"UseCase: {context.Variables.GetValueOrDefault<string>("useCaseId", "Unknown")}")</message>
</trace>
</inbound>

Step 4: Create Scoped Workbooks per Use Case

// Team A can only query their logs
// Grant "Log Analytics Reader" on specific APIM resources or use custom table RBAC

AzureDiagnostics
| where ResourceProvider == "MICROSOFT.APIMANAGEMENT"
| where ApiId_s startswith "uc-a-" // Use Case A APIs only
| project TimeGenerated, ApiId_s, OperationId, ResponseCode_d, DurationMs_d
| order by TimeGenerated desc

Step 5: RBAC Role Assignments

// Grant Team A access ONLY to their API resources
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(resourceGroup().id, 'TeamA', 'LogReader')
scope: apiUseCaseA // Scope to specific API resource
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions',
'73c42c96-874c-492b-b04d-ab87d138a893') // Log Analytics Reader
principalId: teamAGroupId
principalType: 'Group'
}
}

Option 2: Separate Log Analytics Workspaces

For strict isolation, use dedicated workspaces per use case with data export.

Data Export Rule (Bicep)

resource dataExportRule 'Microsoft.OperationalInsights/workspaces/dataExports@2020-08-01' = {
name: 'export-to-team-a'
parent: centralWorkspace
properties: {
destination: {
resourceId: teamAWorkspace.id
}
tableNames: [
'AzureDiagnostics'
]
enable: true
}
}

Summary: Log Isolation Decision Matrix

RequirementRecommended Approach
Simple team separationResource-Context RBAC
Regulatory complianceSeparate Workspaces
Cross-use-case analytics neededCentral + Scoped Views
Cost optimization prioritySingle Workspace + RBAC

2. Distinguishing Internal vs External Calls

❓ Question

How can calls from internal and external systems be distinguished from each other sensibly? External calls come via the Front Door and internal calls come via the Application Gateway. How can crosstalk be prevented?

🎯 Solution: Header-Based Identification + Network Isolation

Implementation Strategy

Step 1: Configure Front Door to Add Identity Header

Front Door automatically adds the X-Azure-FDID header with your Front Door instance ID.

resource frontDoor 'Microsoft.Cdn/profiles@2023-05-01' = {
name: 'fd-api-gateway'
location: 'global'
sku: {
name: 'Premium_AzureFrontDoor'
}
// Front Door ID is automatically included in X-Azure-FDID header
}

Step 2: Configure Application Gateway to Add Custom Header

resource appGateway 'Microsoft.Network/applicationGateways@2023-05-01' = {
name: 'appgw-internal'
properties: {
rewriteRuleSets: [
{
name: 'AddInternalHeader'
properties: {
rewriteRules: [
{
name: 'MarkAsInternal'
ruleSequence: 100
actionSet: {
requestHeaderConfigurations: [
{
headerName: 'X-Traffic-Source'
headerValue: 'internal-appgw'
}
{
headerName: 'X-Internal-Gateway-Secret'
headerValue: '{{internal-gateway-secret}}' // From Key Vault
}
]
}
}
]
}
}
]
}
}

Step 3: APIM Policy to Detect and Route Traffic

<policies>
<inbound>
<base />

<!-- Detect traffic source -->
<set-variable name="trafficSource" value="@{
// Check for Front Door header (external)
var fdId = context.Request.Headers.GetValueOrDefault("X-Azure-FDID", "");
var expectedFdId = "{{front-door-id}}"; // Named value from Key Vault

// Check for Application Gateway header (internal)
var internalSecret = context.Request.Headers.GetValueOrDefault("X-Internal-Gateway-Secret", "");
var expectedSecret = "{{internal-gateway-secret}}"; // Named value from Key Vault

if (!string.IsNullOrEmpty(fdId) && fdId == expectedFdId) {
return "external";
}
else if (!string.IsNullOrEmpty(internalSecret) && internalSecret == expectedSecret) {
return "internal";
}
else {
return "unknown";
}
}" />

<!-- Block unknown traffic sources -->
<choose>
<when condition="@(context.Variables.GetValueOrDefault<string>("trafficSource") == "unknown")">
<return-response>
<set-status code="403" reason="Forbidden" />
<set-body>{"error": "Direct access not allowed. Use authorized gateway."}</set-body>
</return-response>
</when>
</choose>

<!-- Add traffic source header for downstream processing -->
<set-header name="X-Traffic-Source" exists-action="override">
<value>@(context.Variables.GetValueOrDefault<string>("trafficSource"))</value>
</set-header>

<!-- Log traffic source for analytics -->
<trace source="TrafficRouter" severity="information">
<message>@($"Traffic Source: {context.Variables["trafficSource"]}, API: {context.Api.Name}")</message>
</trace>
</inbound>
</policies>

Step 4: Prevent Crosstalk - API-Level Restrictions

<!-- For INTERNAL-ONLY APIs -->
<inbound>
<base />
<choose>
<when condition="@(context.Variables.GetValueOrDefault<string>("trafficSource") != "internal")">
<return-response>
<set-status code="403" reason="Forbidden" />
<set-body>{"error": "This API is only accessible from internal systems"}</set-body>
</return-response>
</when>
</choose>
</inbound>

<!-- For EXTERNAL-ONLY APIs -->
<inbound>
<base />
<choose>
<when condition="@(context.Variables.GetValueOrDefault<string>("trafficSource") != "external")">
<return-response>
<set-status code="403" reason="Forbidden" />
<set-body>{"error": "This API is only accessible from external systems"}</set-body>
</return-response>
</when>
</choose>
</inbound>

Step 5: Network-Level Prevention (Defense in Depth)

// NSG rules to ensure APIM only accepts traffic from known gateways
resource apimNsg 'Microsoft.Network/networkSecurityGroups@2023-05-01' = {
name: 'nsg-apim-subnet'
properties: {
securityRules: [
{
name: 'AllowFrontDoor'
properties: {
priority: 100
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: 'AzureFrontDoor.Backend'
destinationPortRange: '443'
destinationAddressPrefix: 'VirtualNetwork'
}
}
{
name: 'AllowAppGateway'
properties: {
priority: 110
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourceAddressPrefix: appGatewaySubnet.properties.addressPrefix
destinationPortRange: '443'
destinationAddressPrefix: 'VirtualNetwork'
}
}
{
name: 'DenyAllOther'
properties: {
priority: 1000
direction: 'Inbound'
access: 'Deny'
protocol: '*'
sourceAddressPrefix: '*'
destinationPortRange: '*'
destinationAddressPrefix: '*'
}
}
]
}
}

Traffic Flow Summary

SourceEntry PointHeaderAccess Level
External ClientFront DoorX-Azure-FDIDExternal APIs only
Internal SystemApplication GatewayX-Internal-Gateway-SecretInternal + Shared APIs
Direct AccessNoneNoneBLOCKED

3. Multiple Origins Behind a Domain

❓ Question

One origin is stored in the Origin Groups at the Application Gateway. How can multiple origins be integrated or addressed behind a specific domain name?

🎯 Solution: Path-Based or Header-Based Routing to Multiple Origins

Option 1: Path-Based Routing (Application Gateway)

resource appGateway 'Microsoft.Network/applicationGateways@2023-05-01' = {
name: 'appgw-multi-origin'
properties: {
// Backend Address Pools (Origins)
backendAddressPools: [
{
name: 'pool-inventory'
properties: {
backendAddresses: [
{ ipAddress: '10.0.1.10' }
{ ipAddress: '10.0.1.11' } // Multiple instances
]
}
}
{
name: 'pool-orders'
properties: {
backendAddresses: [
{ ipAddress: '10.0.2.10' }
]
}
}
{
name: 'pool-customers'
properties: {
backendAddresses: [
{ ipAddress: '10.0.3.10' }
]
}
}
]

// URL Path Maps for routing
urlPathMaps: [
{
name: 'pathmap-api'
properties: {
defaultBackendAddressPool: {
id: resourceId('Microsoft.Network/applicationGateways/backendAddressPools',
'appgw-multi-origin', 'pool-inventory')
}
defaultBackendHttpSettings: {
id: resourceId('Microsoft.Network/applicationGateways/backendHttpSettingsCollection',
'appgw-multi-origin', 'https-settings')
}
pathRules: [
{
name: 'inventory-path'
properties: {
paths: ['/api/inventory/*']
backendAddressPool: {
id: resourceId('Microsoft.Network/applicationGateways/backendAddressPools',
'appgw-multi-origin', 'pool-inventory')
}
backendHttpSettings: {
id: resourceId('Microsoft.Network/applicationGateways/backendHttpSettingsCollection',
'appgw-multi-origin', 'https-settings')
}
}
}
{
name: 'orders-path'
properties: {
paths: ['/api/orders/*']
backendAddressPool: {
id: resourceId('Microsoft.Network/applicationGateways/backendAddressPools',
'appgw-multi-origin', 'pool-orders')
}
backendHttpSettings: {
id: resourceId('Microsoft.Network/applicationGateways/backendHttpSettingsCollection',
'appgw-multi-origin', 'https-settings')
}
}
}
{
name: 'customers-path'
properties: {
paths: ['/api/customers/*']
backendAddressPool: {
id: resourceId('Microsoft.Network/applicationGateways/backendAddressPools',
'appgw-multi-origin', 'pool-customers')
}
backendHttpSettings: {
id: resourceId('Microsoft.Network/applicationGateways/backendHttpSettingsCollection',
'appgw-multi-origin', 'https-settings')
}
}
}
]
}
}
]

// Routing Rule linking listener to path map
requestRoutingRules: [
{
name: 'rule-api-routing'
properties: {
ruleType: 'PathBasedRouting'
priority: 100
httpListener: {
id: resourceId('Microsoft.Network/applicationGateways/httpListeners',
'appgw-multi-origin', 'listener-https')
}
urlPathMap: {
id: resourceId('Microsoft.Network/applicationGateways/urlPathMaps',
'appgw-multi-origin', 'pathmap-api')
}
}
}
]
}
}

Option 2: Header-Based Routing (Rewrite Rules)

resource appGateway 'Microsoft.Network/applicationGateways@2023-05-01' = {
properties: {
rewriteRuleSets: [
{
name: 'header-based-routing'
properties: {
rewriteRules: [
{
name: 'route-by-api-version'
ruleSequence: 100
conditions: [
{
variable: 'http_req_X-API-Version'
pattern: 'v2'
ignoreCase: true
}
]
actionSet: {
urlConfiguration: {
modifiedPath: '/v2{var_uri_path}'
reroute: true
}
}
}
]
}
}
]
}
}

Option 3: Front Door with Multiple Origins

resource frontDoorOriginGroup 'Microsoft.Cdn/profiles/originGroups@2023-05-01' = {
name: 'og-api-backends'
parent: frontDoor
properties: {
loadBalancingSettings: {
sampleSize: 4
successfulSamplesRequired: 3
additionalLatencyInMilliseconds: 50
}
healthProbeSettings: {
probePath: '/health'
probeProtocol: 'Https'
probeIntervalInSeconds: 30
}
}
}

// Multiple origins in the group with priority/weight
resource origin1 'Microsoft.Cdn/profiles/originGroups/origins@2023-05-01' = {
name: 'origin-primary'
parent: frontDoorOriginGroup
properties: {
hostName: 'api-primary.internal.company.com'
httpPort: 80
httpsPort: 443
priority: 1
weight: 1000
enabledState: 'Enabled'
}
}

resource origin2 'Microsoft.Cdn/profiles/originGroups/origins@2023-05-01' = {
name: 'origin-secondary'
parent: frontDoorOriginGroup
properties: {
hostName: 'api-secondary.internal.company.com'
httpPort: 80
httpsPort: 443
priority: 2 // Failover
weight: 1000
enabledState: 'Enabled'
}
}

Routing Architecture Summary

Routing TypeUse CaseConfiguration
Path-basedDifferent services per URL pathURL Path Maps
Header-basedVersion routing, A/B testingRewrite Rules
Priority-basedActive/Passive failoverOrigin priority
Weight-basedLoad distributionOrigin weights
Health-basedAutomatic failoverHealth probes

Customer Scenario: "We have multiple Application Gateways in different regions/contexts. How can they all sit behind a single domain name, and they are with Private Links?"

This is the recommended enterprise architecture for multi-region or multi-team scenarios where each team owns their Application Gateway but needs unified domain access.

Key Components:

ComponentPurpose
Azure Front Door PremiumSingle global entry point with custom domain
Origin GroupContains all Application Gateways as private origins
Private LinkSecure connectivity from AFD to each App Gateway
Priority/Weight/LatencyLoad balancing across App Gateways

Step-by-Step Implementation

// Each Application Gateway must have Private Link configured
resource appGateway1 'Microsoft.Network/applicationGateways@2023-09-01' = {
name: 'appgw-westeurope'
location: 'westeurope'
properties: {
// ... gateway configuration
privateLinkConfigurations: [
{
name: 'pl-config-appgw1'
properties: {
ipConfigurations: [
{
name: 'pl-ip-config'
properties: {
primary: true
privateIPAllocationMethod: 'Dynamic'
subnet: {
id: privateLinkSubnetId
}
}
}
]
}
}
]
}
}
Step 2: Create Azure Front Door Premium with Origin Group
resource frontDoor 'Microsoft.Cdn/profiles@2023-05-01' = {
name: 'fd-global-api'
location: 'global'
sku: {
name: 'Premium_AzureFrontDoor' // Premium required for Private Link
}
}

resource originGroup 'Microsoft.Cdn/profiles/originGroups@2023-05-01' = {
name: 'og-appgateways'
parent: frontDoor
properties: {
loadBalancingSettings: {
sampleSize: 4
successfulSamplesRequired: 3
additionalLatencyInMilliseconds: 50 // Latency sensitivity
}
healthProbeSettings: {
probePath: '/health'
probeProtocol: 'Https'
probeRequestType: 'HEAD'
probeIntervalInSeconds: 30
}
}
}
Step 3: Add Application Gateways as Private Origins
// Origin 1: West Europe (Primary)
resource origin1 'Microsoft.Cdn/profiles/originGroups/origins@2023-05-01' = {
name: 'origin-appgw-westeurope'
parent: originGroup
properties: {
hostName: 'appgw-westeurope.company.internal' // Listener hostname
originHostHeader: 'api.company.com' // Same as custom domain
httpPort: 80
httpsPort: 443
priority: 1 // Primary
weight: 1000 // Equal weight with other primaries
enabledState: 'Enabled'
sharedPrivateLinkResource: {
privateLink: {
id: '/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/privateLinkServices/_e41f87a2_appgw-westeurope_pl-config-appgw1'
}
privateLinkLocation: 'westeurope'
requestMessage: 'Front Door Private Link Request'
groupId: '' // Empty for Private Link Service
}
}
}

// Origin 2: North Europe (Primary)
resource origin2 'Microsoft.Cdn/profiles/originGroups/origins@2023-05-01' = {
name: 'origin-appgw-northeurope'
parent: originGroup
properties: {
hostName: 'appgw-northeurope.company.internal'
originHostHeader: 'api.company.com'
httpPort: 80
httpsPort: 443
priority: 1 // Primary (same as origin1)
weight: 1000 // Load balanced with origin1
enabledState: 'Enabled'
sharedPrivateLinkResource: {
privateLink: {
id: '/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/privateLinkServices/_e41f87a2_appgw-northeurope_pl-config-appgw2'
}
privateLinkLocation: 'northeurope'
requestMessage: 'Front Door Private Link Request'
groupId: ''
}
}
}

// Origin 3: Germany West Central (Backup)
resource origin3 'Microsoft.Cdn/profiles/originGroups/origins@2023-05-01' = {
name: 'origin-appgw-germanywestcentral'
parent: originGroup
properties: {
hostName: 'appgw-gwc.company.internal'
originHostHeader: 'api.company.com'
httpPort: 80
httpsPort: 443
priority: 2 // Backup (only used if all priority-1 origins fail)
weight: 1000
enabledState: 'Enabled'
sharedPrivateLinkResource: {
privateLink: {
id: '/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/privateLinkServices/_e41f87a2_appgw-gwc_pl-config-appgw3'
}
privateLinkLocation: 'germanywestcentral'
requestMessage: 'Front Door Private Link Request'
groupId: ''
}
}
}
Step 4: Approve Private Endpoints on Each Application Gateway

After creating the origins, you must approve the Private Endpoint connections:

# List pending connections
az network application-gateway private-link show \
--gateway-name appgw-westeurope \
--resource-group rg-networking \
--name pl-config-appgw1

# Approve the connection
az network private-endpoint-connection approve \
--name <connection-name> \
--resource-group rg-networking \
--resource-name appgw-westeurope \
--type Microsoft.Network/applicationGateways

Traffic Routing Decision Flow

Key Considerations

ConsiderationRecommendation
Same Origin Host HeaderUse the same originHostHeader (e.g., api.company.com) across all origins
Certificate ValidationEach App Gateway listener must have a valid certificate for the host header
Private Link ApprovalEach origin creates a separate Private Endpoint that must be approved
Regional RedundancyUse different Private Link regions for each origin for cluster redundancy
No Mixed OriginsCannot mix public and private origins in the same origin group

Option 5: Different Backend Hostnames per Application Gateway (Rules Engine)

Customer Constraint: "We cannot use the same backend hostname across all Application Gateways. Each App Gateway has its own listener hostname."

When each Application Gateway has a different listener hostname and you cannot standardize them, use Azure Front Door Rules Engine to dynamically route and rewrite requests.

Architecture: Multiple Origin Groups + Rules Engine

When backend hostnames differ, create separate Origin Groups and use Rules Engine to route based on path or headers.

Step 1: Create Separate Origin Groups (One per App Gateway)
// Origin Group for Team A
resource originGroupA 'Microsoft.Cdn/profiles/originGroups@2023-05-01' = {
name: 'og-team-a'
parent: frontDoor
properties: {
loadBalancingSettings: {
sampleSize: 4
successfulSamplesRequired: 3
additionalLatencyInMilliseconds: 50
}
healthProbeSettings: {
probePath: '/health'
probeProtocol: 'Https'
probeIntervalInSeconds: 30
}
}
}

resource originTeamA 'Microsoft.Cdn/profiles/originGroups/origins@2023-05-01' = {
name: 'origin-appgw-team-a'
parent: originGroupA
properties: {
hostName: 'teamA.appgw.internal'
originHostHeader: 'teamA.appgw.internal' // Different per origin
httpsPort: 443
priority: 1
weight: 1000
enabledState: 'Enabled'
sharedPrivateLinkResource: {
privateLink: { id: teamAPrivateLinkId }
privateLinkLocation: 'westeurope'
requestMessage: 'AFD connection'
groupId: ''
}
}
}

// Origin Group for Team B
resource originGroupB 'Microsoft.Cdn/profiles/originGroups@2023-05-01' = {
name: 'og-team-b'
parent: frontDoor
properties: {
loadBalancingSettings: {
sampleSize: 4
successfulSamplesRequired: 3
additionalLatencyInMilliseconds: 50
}
healthProbeSettings: {
probePath: '/health'
probeProtocol: 'Https'
probeIntervalInSeconds: 30
}
}
}

resource originTeamB 'Microsoft.Cdn/profiles/originGroups/origins@2023-05-01' = {
name: 'origin-appgw-team-b'
parent: originGroupB
properties: {
hostName: 'teamB.appgw.internal'
originHostHeader: 'teamB.appgw.internal' // Different per origin
httpsPort: 443
priority: 1
weight: 1000
enabledState: 'Enabled'
sharedPrivateLinkResource: {
privateLink: { id: teamBPrivateLinkId }
privateLinkLocation: 'northeurope'
requestMessage: 'AFD connection'
groupId: ''
}
}
}
Step 2: Create Rules Engine to Override Origin Group
resource ruleSet 'Microsoft.Cdn/profiles/ruleSets@2023-05-01' = {
name: 'routing-rules'
parent: frontDoor
}

// Rule 1: Route /team-a/* to Origin Group A
resource ruleTeamA 'Microsoft.Cdn/profiles/ruleSets/rules@2023-05-01' = {
name: 'route-team-a'
parent: ruleSet
properties: {
order: 1
conditions: [
{
name: 'UrlPath'
parameters: {
typeName: 'DeliveryRuleUrlPathMatchConditionParameters'
operator: 'BeginsWith'
matchValues: ['/team-a/']
transforms: ['Lowercase']
}
}
]
actions: [
{
name: 'RouteConfigurationOverride'
parameters: {
typeName: 'DeliveryRuleRouteConfigurationOverrideActionParameters'
originGroupOverride: {
originGroup: {
id: originGroupA.id
}
forwardingProtocol: 'HttpsOnly'
}
}
}
]
}
}

// Rule 2: Route /team-b/* to Origin Group B
resource ruleTeamB 'Microsoft.Cdn/profiles/ruleSets/rules@2023-05-01' = {
name: 'route-team-b'
parent: ruleSet
properties: {
order: 2
conditions: [
{
name: 'UrlPath'
parameters: {
typeName: 'DeliveryRuleUrlPathMatchConditionParameters'
operator: 'BeginsWith'
matchValues: ['/team-b/']
transforms: ['Lowercase']
}
}
]
actions: [
{
name: 'RouteConfigurationOverride'
parameters: {
typeName: 'DeliveryRuleRouteConfigurationOverrideActionParameters'
originGroupOverride: {
originGroup: {
id: originGroupB.id
}
forwardingProtocol: 'HttpsOnly'
}
}
}
]
}
}
Step 3: Associate Rules Engine with Route
resource route 'Microsoft.Cdn/profiles/afdEndpoints/routes@2023-05-01' = {
name: 'default-route'
parent: endpoint
properties: {
customDomains: [
{ id: customDomain.id }
]
originGroup: { id: originGroupA.id } // Default origin group
patternsToMatch: ['/*']
forwardingProtocol: 'HttpsOnly'
httpsRedirect: 'Enabled'
linkToDefaultDomain: 'Enabled'
ruleSets: [
{ id: ruleSet.id } // Apply rules engine
]
}
}

Alternative: Header-Based Routing

If path-based routing doesn't work, use custom headers to determine routing:

resource ruleByHeader 'Microsoft.Cdn/profiles/ruleSets/rules@2023-05-01' = {
name: 'route-by-team-header'
parent: ruleSet
properties: {
order: 1
conditions: [
{
name: 'RequestHeader'
parameters: {
typeName: 'DeliveryRuleRequestHeaderConditionParameters'
headerName: 'X-Target-Team'
operator: 'Equal'
matchValues: ['team-a']
transforms: ['Lowercase']
}
}
]
actions: [
{
name: 'RouteConfigurationOverride'
parameters: {
typeName: 'DeliveryRuleRouteConfigurationOverrideActionParameters'
originGroupOverride: {
originGroup: { id: originGroupA.id }
forwardingProtocol: 'HttpsOnly'
}
}
}
]
}
}

Architecture Comparison: Same vs Different Backend Hostnames

ScenarioArchitectureComplexityRecommendation
Same hostname across all App GatewaysSingle Origin Group with multiple originsLowPreferred - Simpler, built-in load balancing
Different hostnames per App GatewayMultiple Origin Groups + Rules EngineMediumUse when standardization is not possible
Path-based routing neededRules Engine with path conditionsMediumWorks with both scenarios
Header-based routing neededRules Engine with header conditionsMediumGood for API versioning or A/B testing

Microsoft Documentation References

TopicLink
Private Link with Front DoorSecure origin with Private Link
App Gateway as Private OriginConnect AFD to Application Gateway
Origin Groups & OriginsOrigins and origin groups
Traffic Routing MethodsRouting methods (Priority, Weight, Latency)
Rules Engine ActionsRoute configuration override
Route MatchingHow requests get matched

Key Takeaways

  1. Azure Front Door Premium is required for Private Link to Application Gateway
  2. Single Origin Group works when all App Gateways can accept the same originHostHeader
  3. Multiple Origin Groups + Rules Engine is needed when App Gateways have different listener hostnames
  4. Private Link connections must be approved individually on each Application Gateway
  5. Cannot mix public and private origins in the same origin group
  6. Regional redundancy is achieved by using different privateLinkLocation values per origin

4. Central Base Policy Enforcement

❓ Question

Can a central base policy be enforced on use case APIs to prevent them from being bypassed?

🎯 Solution: Yes! Use Global, Product, and API Policy Inheritance

Policy Execution Order

Request Flow:  Global(in) → Product(in) → API(in) → Operation(in) → Backend
Response Flow: Operation(out) → API(out) → Product(out) → Global(out) → Client

Step 1: Define Mandatory Global Policy

<!-- GLOBAL POLICY: All-APIs scope - Cannot be bypassed! -->
<policies>
<inbound>
<!-- ============================================ -->
<!-- MANDATORY SECURITY CONTROLS - DO NOT REMOVE -->
<!-- ============================================ -->

<!-- 1. Enforce HTTPS only -->
<choose>
<when condition="@(context.Request.OriginalUrl.Scheme != "https")">
<return-response>
<set-status code="403" reason="HTTPS Required" />
<set-body>{"error": "HTTPS is mandatory"}</set-body>
</return-response>
</when>
</choose>

<!-- 2. Global rate limiting (prevent abuse) -->
<rate-limit-by-key calls="1000"
renewal-period="60"
counter-key="@(context.Request.IpAddress)" />

<!-- 3. Mandatory request ID for tracing -->
<set-header name="X-Request-ID" exists-action="skip">
<value>@(Guid.NewGuid().ToString())</value>
</set-header>

<!-- 4. Block deprecated API versions -->
<choose>
<when condition="@(context.Request.Headers.GetValueOrDefault("api-version", "") == "v1")">
<return-response>
<set-status code="410" reason="Gone" />
<set-body>{"error": "API version v1 is deprecated. Use v2 or later."}</set-body>
</return-response>
</when>
</choose>

<!-- 5. Security headers injection -->
<set-header name="X-Content-Type-Options" exists-action="override">
<value>nosniff</value>
</set-header>

<!-- Allow child policies to execute -->
<base />
</inbound>

<backend>
<base />
</backend>

<outbound>
<!-- Execute child policies first -->
<base />

<!-- ============================================ -->
<!-- MANDATORY RESPONSE CONTROLS -->
<!-- ============================================ -->

<!-- 1. Remove internal headers -->
<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" />

<!-- 2. Add security headers -->
<set-header name="X-Frame-Options" exists-action="override">
<value>DENY</value>
</set-header>
<set-header name="Strict-Transport-Security" exists-action="override">
<value>max-age=31536000; includeSubDomains</value>
</set-header>

<!-- 3. CORS enforcement (if applicable) -->
<cors allow-credentials="false">
<allowed-origins>
<origin>https://portal.company.com</origin>
</allowed-origins>
<allowed-methods>
<method>GET</method>
<method>POST</method>
</allowed-methods>
</cors>
</outbound>

<on-error>
<base />
<!-- Global error handling -->
<set-header name="X-Error-Source" exists-action="override">
<value>APIM-Gateway</value>
</set-header>
</on-error>
</policies>

Step 2: Define Product-Level Policies

<!-- PRODUCT POLICY: e.g., "Internal APIs" product -->
<policies>
<inbound>
<!-- Inherit global policy -->
<base />

<!-- Product-specific: Require internal traffic source -->
<choose>
<when condition="@(context.Variables.GetValueOrDefault<string>("trafficSource") != "internal")">
<return-response>
<set-status code="403" reason="Forbidden" />
<set-body>{"error": "This product requires internal access"}</set-body>
</return-response>
</when>
</choose>

<!-- Product-specific rate limit (more restrictive than global) -->
<rate-limit-by-key calls="100"
renewal-period="60"
counter-key="@(context.Subscription.Id)" />
</inbound>

<outbound>
<base />
</outbound>
</policies>

Step 3: API-Level Policy (Use Case Specific)

<!-- API POLICY: Use case team can customize, but cannot remove <base /> -->
<policies>
<inbound>
<!-- MUST include base to inherit Global + Product policies -->
<base />

<!-- API-specific: JWT validation for this API -->
<validate-azure-ad-token tenant-id="{{tenant-id}}">
<audiences>
<audience>api://inventory-api</audience>
</audiences>
</validate-azure-ad-token>
</inbound>

<outbound>
<base />
</outbound>
</policies>

Enforcement: Azure Policy to Ensure <base /> is Present

{
"if": {
"allOf": [
{
"field": "type",
"equals": "Microsoft.ApiManagement/service/apis/policies"
},
{
"field": "Microsoft.ApiManagement/service/apis/policies/value",
"notContains": "<base />"
}
]
},
"then": {
"effect": "deny"
}
}

Policy Inheritance Matrix

Policy LevelScopeCan Be Bypassed?Who Manages
GlobalAll APIs❌ NoPlatform Team
ProductAPI groups⚠️ Only if removed from productPlatform Team
APISingle API✅ Yes (own scope)Use Case Team
OperationSingle endpoint✅ Yes (own scope)Use Case Team

5. Schema Validation Approach

❓ Question

What would be a sensible approach to schema validation in API management?

🎯 Solution: Multi-Layer Validation Strategy

Step 1: Import API with OpenAPI Specification

# openapi.yaml
openapi: 3.0.3
info:
title: Inventory API
version: '2.0'
paths:
/items:
post:
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateItem'
responses:
'201':
description: Created
components:
schemas:
CreateItem:
type: object
required:
- name
- sku
- price
properties:
name:
type: string
minLength: 1
maxLength: 100
sku:
type: string
pattern: '^[A-Z]{3}-[0-9]{6}$'
price:
type: number
minimum: 0.01
maximum: 999999.99
quantity:
type: integer
minimum: 0
default: 0

Step 2: Enable Content Validation Policy

<inbound>
<base />

<!-- Validate request body against OpenAPI schema -->
<validate-content
unspecified-content-type-action="prevent"
max-size="102400"
size-exceeded-action="prevent"
errors-variable-name="requestBodyValidation">

<!-- JSON validation against imported OpenAPI schema -->
<content type="application/json" validate-as="json" action="prevent" />
</validate-content>

<!-- Return detailed validation errors -->
<choose>
<when condition="@(context.Variables.ContainsKey("requestBodyValidation"))">
<return-response>
<set-status code="400" reason="Bad Request" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-body>@{
var errors = ((List<object>)context.Variables["requestBodyValidation"])
.Select(e => e.ToString()).ToArray();
return new JObject(
new JProperty("error", "Request validation failed"),
new JProperty("details", new JArray(errors))
).ToString();
}</set-body>
</return-response>
</when>
</choose>
</inbound>

Approach 2: Custom JSON Schema Validation

<inbound>
<base />

<!-- Store expected schema -->
<set-variable name="expectedSchema" value="@{
return new JObject(
new JProperty("type", "object"),
new JProperty("required", new JArray("name", "email")),
new JProperty("properties", new JObject(
new JProperty("name", new JObject(
new JProperty("type", "string"),
new JProperty("minLength", 1)
)),
new JProperty("email", new JObject(
new JProperty("type", "string"),
new JProperty("pattern", @"^[^@]+@[^@]+\.[^@]+$")
))
))
).ToString();
}" />

<!-- Validate against schema using expression -->
<set-variable name="validationResult" value="@{
try {
var body = context.Request.Body.As<JObject>(preserveContent: true);

// Check required fields
if (body["name"] == null || body["email"] == null) {
return "Missing required fields: name, email";
}

// Check email format
var email = body["email"]?.ToString() ?? "";
if (!System.Text.RegularExpressions.Regex.IsMatch(email, @"^[^@]+@[^@]+\.[^@]+$")) {
return "Invalid email format";
}

return "valid";
}
catch (Exception ex) {
return $"Parse error: {ex.Message}";
}
}" />

<choose>
<when condition="@(context.Variables.GetValueOrDefault<string>("validationResult") != "valid")">
<return-response>
<set-status code="400" reason="Bad Request" />
<set-body>@{
return new JObject(
new JProperty("error", context.Variables["validationResult"])
).ToString();
}</set-body>
</return-response>
</when>
</choose>
</inbound>

Approach 3: Header and Parameter Validation

<inbound>
<base />

<!-- Validate required headers -->
<validate-headers specified-header-action="ignore" unspecified-header-action="ignore">
<header name="Content-Type" action="prevent">
<value>application/json</value>
</header>
<header name="X-Correlation-ID" action="prevent" />
</validate-headers>

<!-- Validate query parameters -->
<validate-parameters specified-parameter-action="prevent"
unspecified-parameter-action="prevent">
<parameter name="api-version" action="prevent">
<value>2023-01-01</value>
<value>2024-01-01</value>
</parameter>
</validate-parameters>
</inbound>

Validation Strategy Summary

Validation TypePolicyUse Case
OpenAPI Schemavalidate-contentStandard REST APIs with spec
Custom JSONExpression-basedComplex business rules
Headersvalidate-headersAPI versioning, correlation
Parametersvalidate-parametersQuery string validation
JWT Claimsvalidate-jwtAuthorization data
Size Limitsvalidate-content max-sizePrevent payload abuse

Best Practices

  1. Fail Fast: Validate at the gateway before reaching backends
  2. Detailed Errors: Return actionable error messages (in dev/test)
  3. Generic Errors in Prod: Don't expose schema details to attackers
  4. Version Your Schemas: Align with API versioning strategy
  5. Performance: Cache schema validations where possible

6. Developer Portal & Entra ID Integration

❓ Question

We have currently deactivated the Developer Portal in API management. What benefits would there be in activating it? How would this work in practice? The same applies to user management via Entra ID.

🎯 Benefits of Activating the Developer Portal

Developer Portal Benefits

BenefitWithout PortalWith Portal
API DiscoveryManual documentationSelf-service browsing
DocumentationSeparate docs siteAuto-generated from OpenAPI
TestingExternal tools (Postman)Built-in "Try It" console
OnboardingManual key distributionSelf-service subscription
Key ManagementAdmin manages all keysDevelopers manage own keys
Usage VisibilityOnly for adminsDevelopers see own usage

When to Activate the Developer Portal

ScenarioRecommendation
Internal APIs only✅ Activate (internal users benefit)
Partner APIs✅ Activate (essential for partners)
Public APIs✅ Activate (required for adoption)
Single consumer⚠️ Optional (limited benefit)
High security / regulated⚠️ Evaluate carefully

Entra ID Integration Architecture

Step-by-Step: Enable Entra ID for Developer Portal

Step 1: Register Application in Entra ID

# Create app registration for Developer Portal
az ad app create \
--display-name "APIM Developer Portal" \
--sign-in-audience "AzureADMyOrg" \
--web-redirect-uris "https://apim-prod.developer.azure-api.net/signin-oauth/code/callback/aad" \
--enable-id-token-issuance true

Step 2: Configure APIM Identity Provider (Bicep)

resource entraIdProvider 'Microsoft.ApiManagement/service/identityProviders@2023-05-01-preview' = {
name: 'aad'
parent: apim
properties: {
type: 'aad'
clientId: entraAppClientId
clientSecret: entraAppClientSecret // Store in Key Vault
allowedTenants: [
tenantId
]
authority: 'https://login.microsoftonline.com/${tenantId}'
signinTenant: tenantId
}
}

Step 3: Configure Portal Authentication

resource portalConfig 'Microsoft.ApiManagement/service/portalsettings/signin@2023-05-01-preview' = {
name: 'signin'
parent: apim
properties: {
enabled: true
}
}

resource portalSignup 'Microsoft.ApiManagement/service/portalsettings/signup@2023-05-01-preview' = {
name: 'signup'
parent: apim
properties: {
enabled: false // Disable self-registration (Entra ID only)
termsOfService: {
enabled: true
consentRequired: true
text: 'Terms of Service text...'
}
}
}

Step 4: Map Entra ID Groups to APIM Groups

// Create APIM group linked to Entra ID group
resource developerGroup 'Microsoft.ApiManagement/service/groups@2023-05-01-preview' = {
name: 'api-developers'
parent: apim
properties: {
displayName: 'API Developers'
description: 'Internal developers with API access'
type: 'external'
externalId: 'aad://${tenantId}/groups/${entraGroupId}' // Link to Entra group
}
}

// Associate group with product
resource productGroup 'Microsoft.ApiManagement/service/products/groups@2023-05-01-preview' = {
name: 'api-developers'
parent: internalApisProduct
}

Security Best Practices for Developer Portal

SettingRecommendationReason
Self-registrationDisableUse Entra ID only
Anonymous accessDisableRequire authentication
Basic authDisableUse Entra ID only
Guest usersEntra B2BControlled partner access
MFAEnable via Conditional AccessSecurity requirement
IP restrictionsEnableLimit portal access

Practical Workflow with Developer Portal

Complete Configuration Checklist

Pre-Activation

  • Entra ID app registration created
  • Redirect URIs configured
  • Client secret stored in Key Vault
  • Entra groups defined for access control

Portal Configuration

  • Entra ID identity provider configured
  • Self-registration disabled
  • Anonymous access disabled
  • Basic authentication disabled
  • Terms of service configured

Access Control

  • APIM groups linked to Entra groups
  • Products associated with groups
  • API visibility configured
  • Subscription approval workflow defined

Security Hardening

  • Conditional Access policies applied
  • MFA required for portal access
  • IP restrictions configured (if needed)
  • CORS configured for custom domains

📚 Summary

QuestionKey Solution
Log IsolationResource-Context RBAC + scoped queries
Internal vs ExternalHeader-based detection + NSG enforcement
Multiple OriginsPath-based routing in App Gateway
Base PolicyGlobal policy with mandatory <base />
Schema Validationvalidate-content with OpenAPI specs
Developer PortalEnable + Entra ID SSO + group-based access

DocumentDescription
02-ReliabilityHA and isolation patterns
03-SecuritySecurity policies
04-PoliciesPolicy patterns
05-DevOpsAPIOps and governance
11-MonetizationProducts and subscriptions

Back to: README - Main documentation index

📖Learn