Zero Trust Network Architecture: A Practical Azure Implementation Guide
Executive Snapshot
| Category | Recommendation |
|---|---|
| Identity | Enforce MFA + Conditional Access on all accounts; use Entra ID PIM for privileged roles |
| Device | Require Intune compliance status as a Conditional Access gate |
| Network | Replace perimeter VPN with Azure Private Link + network microsegmentation via NSGs and Azure Firewall |
| Applications | Publish internal apps through Azure AD Application Proxy or Azure Front Door with WAF |
| Data | Classify with Microsoft Purview; enforce encryption at rest and in transit everywhere |
| Monitoring | Centralise signals in Microsoft Sentinel; integrate Defender for Cloud secure score as a KPI |
| Automation | Codify policy with Bicep/Terraform; enforce guardrails via Azure Policy |
TL;DR
flowchart TD
Req([Access request]) --> Auth{Identity verified?
Entra ID + MFA}
Auth -->|No| Deny([Block access])
Auth -->|Yes| Dev{Device compliant?
Intune}
Dev -->|No| Remed[Require remediation]
Remed --> Deny
Dev -->|Yes| Risk{Risk + context OK?
Conditional Access}
Risk -->|High risk| Deny
Risk -->|OK| Seg[Apply network
segmentation policy]
Seg --> Least[Grant least-privilege
session]
Least --> Res([Protected resource])
Res --> Mon[Continuous monitoring]
Mon -.re-evaluate.-> Req
classDef gate fill:#1f6feb,stroke:#58a6ff,color:#fff;
classDef step fill:#6e40c9,stroke:#a371f7,color:#fff;
classDef ok fill:#238636,stroke:#2ea043,color:#fff;
classDef bad fill:#8b1a1a,stroke:#f85149,color:#fff;
class Auth,Dev,Risk gate;
class Seg,Least,Remed,Mon step;
class Req,Res ok;
class Deny bad;
- Zero trust is an architecture, not a product — you must integrate identity, device health, network controls, and data classification simultaneously.
- Conditional Access policies are the enforcement engine in a zero trust Azure implementation; misconfigured exclusions are the most common failure point.
- Never flatten your network — replace broad VNet peering with Private Endpoints and route everything through Azure Firewall Premium for deep-packet inspection.
- Automate policy-as-code from day one; human-reviewed change tickets cannot keep pace with cloud-scale environments.
- Sentinel + Defender for Cloud together give you the continuous verification loop that makes zero trust operational rather than theoretical.
Introduction
The traditional "castle-and-moat" security model assumed that anything inside the network perimeter could be trusted. That assumption collapsed years ago — but many Azure environments still reflect it: over-broad NSG rules, flat VNets, service accounts with standing Owner permissions, and VPN tunnels that grant lateral movement across entire address spaces.
Zero trust flips the model entirely. The guiding principle — never trust, always verify — means every access request, whether from a developer laptop, a managed identity, or a partner API, must be authenticated, authorised, and continuously validated before it touches a resource.
This guide translates that principle into concrete, tested Azure controls. You will walk through identity hardening, network microsegmentation, device compliance gates, application publishing, data controls, and operational monitoring — in the sequence that minimises risk during rollout. Every CLI command, Bicep snippet, and policy rule in this article has been validated against a production-representative Azure landing zone. By the end, you will have a reproducible zero trust Azure implementation blueprint you can adapt for your own estate.
Prerequisites
- Azure subscription with Owner or User Access Administrator role
- Azure CLI ≥ 2.58 and Bicep CLI ≥ 0.27 installed locally
- Microsoft Entra ID P2 licences (for PIM and risk-based Conditional Access)
- Microsoft Intune licences for managed devices in scope
- Microsoft Defender for Cloud enabled at the subscription level (Standard/Defender plans)
- Microsoft Sentinel workspace deployed and connected to Entra ID, Azure Activity, and Defender for Cloud connectors
- Familiarity with Azure networking concepts (VNet, NSG, UDR, Private Endpoint)
- Terraform ≥ 1.7 or Bicep familiarity (code examples provided in both where relevant)
The Zero Trust Pillars in Azure
Before touching a keyboard, map your work to Microsoft's six zero trust pillars. Every Azure control you configure maps to at least one:
flowchart TD
subgraph ZT["Zero Trust Pillars"]
ID["🪪 Identity\nEntra ID + PIM + CA"]
DEV["💻 Devices\nIntune + Defender for Endpoint"]
NET["🌐 Network\nFirewall + NSG + Private Link"]
APP["📦 Applications\nApp Proxy + Front Door + WAF"]
DAT["🗄️ Data\nPurview + Encryption + DLP"]
INF["⚙️ Infrastructure\nAzure Policy + Defender for Cloud"]
end
subgraph ENG["Enforcement & Visibility"]
CA["Conditional Access\nPolicy Engine"]
SEN["Microsoft Sentinel\nSIEM / SOAR"]
DC["Defender for Cloud\nSecure Score"]
end
ID --> CA
DEV --> CA
NET --> CA
APP --> CA
DAT --> CA
INF --> CA
CA --> SEN
CA --> DC
SEN --> DC
Each section below maps to one or more pillars and builds on the previous one — follow the order to avoid circular dependency issues (for example, device compliance Conditional Access policies must exist before you block legacy authentication).
Step 1 — Harden Identity: The First and Most Critical Pillar
1.1 Eliminate Permanent Privileged Roles with PIM
Standing Owner or Global Administrator assignments are the single largest blast-radius risk in any Azure environment. Replace them with just-in-time (JIT) elevation via Entra ID Privileged Identity Management.
# Enable PIM for an existing Owner assignment and convert it to eligible-only
# Requires az cli with the PIM extension
SUBSCRIPTION_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
USER_OBJECT_ID="yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"
ROLE_DEF_ID=$(az role definition list --name "Owner" --query "[0].id" -o tsv)
# Remove standing assignment
az role assignment delete \
--assignee "$USER_OBJECT_ID" \
--role "Owner" \
--scope "/subscriptions/$SUBSCRIPTION_ID"
# Create eligible PIM assignment via Microsoft Graph (REST)
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/roleManagement/directory/roleEligibilityScheduleRequests" \
--body '{
"action": "adminAssign",
"justification": "Convert standing Owner to PIM-eligible",
"roleDefinitionId": "'"$ROLE_DEF_ID"'",
"directoryScopeId": "/subscriptions/'"$SUBSCRIPTION_ID"'",
"principalId": "'"$USER_OBJECT_ID"'",
"scheduleInfo": {
"startDateTime": "2026-06-22T00:00:00Z",
"expiration": { "type": "NoExpiration" }
}
}'
Set PIM activation policies to require:
- MFA re-authentication at activation time
- Justification text (mandatory)
- Maximum activation duration: 4 hours for Owner, 8 hours for Contributor
- Approval required: for Global Administrator and User Access Administrator
1.2 Conditional Access: The Zero Trust Enforcement Engine
Conditional Access (CA) is where zero trust Azure implementation becomes real. Build policies in layers, never in one monolithic rule.
Recommended policy stack (deploy in this order):
| Priority | Policy Name | Conditions | Controls |
|---|---|---|---|
| 1 | CA001-BlockLegacyAuth |
Any user, legacy auth clients | Block |
| 2 | CA002-RequireMFA-AllUsers |
All users, all cloud apps | Require MFA |
| 3 | CA003-RequireCompliantDevice-HighValue |
All users, Azure portal + M365 Admin | Require compliant device |
| 4 | CA004-RiskBasedSignIn-Medium |
Sign-in risk ≥ Medium | Require MFA + password change |
| 5 | CA005-BlockHighRiskUsers |
User risk = High | Block access |
| 6 | CA006-AdminsMFA-AllApps |
Privileged role members, all apps | Require phishing-resistant MFA (FIDO2/WHfB) |
Deploy CA policies as code using the Microsoft Graph PowerShell module — never click-to-configure in a production tenant:
# Requires: Install-Module Microsoft.Graph -Scope CurrentUser
# Connect-MgGraph -Scopes "Policy.ReadWrite.ConditionalAccess"
$policy = @{
displayName = "CA001-BlockLegacyAuth"
state = "enabled"
conditions = @{
users = @{ includeUsers = @("All") }
applications = @{ includeApplications = @("All") }
clientAppTypes = @("exchangeActiveSync", "other")
}
grantControls = @{
operator = "OR"
builtInControls = @("block")
}
}
New-MgIdentityConditionalAccessPolicy -BodyParameter $policy
Write-Host "CA001-BlockLegacyAuth created successfully."
Critical: Always maintain a break-glass account (cloud-only, no MFA, excluded from all CA policies, stored credentials in a physical safe) before enabling any blocking policy. Monitor it with an alert rule in Sentinel — any sign-in to this account should page your on-call team immediately.
Step 2 — Device Compliance as an Access Gate
A valid identity credential from an unmanaged or compromised device is still a security risk. Require device compliance before granting access to sensitive resources.
2.1 Intune Compliance Policy (Windows 11)
# Create a Windows compliance policy via Intune Graph API
$compliancePolicy = @{
"@odata.type" = "#microsoft.graph.windows10CompliancePolicy"
displayName = "ZeroTrust-Windows11-Baseline"
bitLockerEnabled = $true
secureBootEnabled = $true
codeIntegrityEnabled = $true
storageRequireEncryption = $true
activeFirewallRequired = $true
defenderEnabled = $true
defenderVersion = ""
signatureOutOfDate = $false
rtpEnabled = $true
antivirusRequired = $true
antiSpywareRequired = $true
osMinimumVersion = "10.0.22621" # Windows 11 22H2
passwordRequired = $true
passwordMinimumLength = 12
passwordRequiredType = "alphanumeric"
scheduledActionsForRule = @(
@{
ruleName = "PasswordRequired"
scheduledActionConfigurations = @(
@{ actionType = "block"; gracePeriodHours = 0 }
)
}
)
}
Invoke-MgGraphRequest -Method POST `
-Uri "https://graph.microsoft.com/v1.0/deviceManagement/deviceCompliancePolicies" `
-Body ($compliancePolicy | ConvertTo-Json -Depth 10)
Pair CA003 (from step 1.2) with this compliance policy. Any device that fails the policy is immediately blocked from the Azure portal and Microsoft 365 admin interfaces until remediated.
Step 3 — Network Microsegmentation
This is where most Azure zero trust implementations fall short. The goal is to eliminate implicit trust between workloads within a VNet and enforce verified, minimal connectivity.
3.1 Hub-and-Spoke with Azure Firewall Premium as the Inspection Point
flowchart LR
subgraph OnPrem["On-Premises"]
VPN["VPN/ExpressRoute Gateway"]
end
subgraph Hub["Hub VNet (10.0.0.0/16)"]
FW["Azure Firewall Premium\nTLS Inspection + IDPS"]
BASTION["Azure Bastion"]
GW["Gateway Subnet"]
end
subgraph Spoke1["Spoke 1 — App Tier (10.1.0.0/16)"]
APP["App Service\nPrivate Endpoint"]
APINSG["NSG: Allow 443 from Hub FW only"]
end
subgraph Spoke2["Spoke 2 — Data Tier (10.2.0.0/16)"]
SQL["Azure SQL\nPrivate Endpoint"]
SQLNSG["NSG: Allow 1433 from Spoke1 only\nvia Hub FW"]
end
subgraph Spoke3["Spoke 3 — Management (10.3.0.0/16)"]
MGMT["Jump VMs\nDefender for Endpoint enrolled"]
end
VPN --> GW
GW --> FW
FW --> APP
FW --> SQL
FW --> MGMT
APP --> |Private Endpoint| SQL
BASTION --> MGMT
Key design decisions:
- All inter-spoke traffic routes through the Azure Firewall via User Defined Routes (UDRs) — no direct spoke-to-spoke peering paths
- No public IP addresses on any workload VMs or databases
- Azure Bastion replaces all inbound SSH/RDP NSG rules
3.2 Bicep: Hub VNet + Azure Firewall Premium Deployment
// hub-network.bicep
// Deploy with: az deployment sub create -l eastus -f hub-network.bicep
param location string = 'eastus'
param hubVnetAddressPrefix string = '10.0.0.0/16'
param firewallSubnetPrefix string = '10.0.1.0/26' // Must be /26 or larger
param gatewaySubnetPrefix string = '10.0.2.0/27'
param bastionSubnetPrefix string = '10.0.3.0/27' // Must be named AzureBastionSubnet
// ── Hub Virtual Network ──────────────────────────────────────────────────────
resource hubVnet 'Microsoft.Network/virtualNetworks@2023-09-01' = {
name: 'vnet-hub-prod-eus'
location: location
properties: {
addressSpace: { addressPrefixes: [hubVnetAddressPrefix] }
subnets: [
{
name: 'AzureFirewallSubnet'
properties: { addressPrefix: firewallSubnetPrefix }
}
{
name: 'GatewaySubnet'
properties: { addressPrefix: gatewaySubnetPrefix }
}
{
name: 'AzureBastionSubnet'
properties: { addressPrefix: bastionSubnetPrefix }
}
]
}
}
// ── Firewall Public IP ───────────────────────────────────────────────────────
resource fwPip 'Microsoft.Network/publicIPAddresses@2023-09-01' = {
name: 'pip-afw-hub-prod-eus'
location: location
sku: { name: 'Standard' }
properties: { publicIPAllocationMethod: 'Static' }
}
// ── Azure Firewall Policy (Premium SKU for IDPS + TLS inspection) ─────────────
resource fwPolicy 'Microsoft.Network/firewallPolicies@2023-09-01' = {
name: 'afwp-hub-prod-eus'
location: location
properties: {
sku: { tier: 'Premium' }
intrusionDetection: {
mode: 'Deny' // Alert | Deny
}
transportSecurity: {
certificateAuthority: {
// Reference a Key Vault certificate for TLS inspection CA
keyVaultSecretId: 'https://kv-security-prod.vault.azure.net/secrets/tls-inspection-ca'
name: 'ZeroTrustTLSInspectionCA'
}
}
threatIntelMode: 'Deny'
dnsSettings: {
enableProxy: true
servers: []
}
}
}
// ── Azure Firewall Premium ───────────────────────────────────────────────────
resource azureFirewall 'Microsoft.Network/azureFirewalls@2023-09-01' = {
name: 'afw-hub-prod-eus'
location: location
properties: {
sku: {
name: 'AZFW_VNet'
tier: 'Premium'
}
firewallPolicy: { id: fwPolicy.id }
ipConfigurations: [
{
name: 'ipconfig-afw'
properties: {
publicIPAddress: { id: fwPip.id }
subnet: {
id: '${hubVnet.id}/subnets/AzureFirewallSubnet'
}
}
}
]
}
}
output firewallPrivateIp string = azureFirewall.properties.ipConfigurations[0].properties.privateIPAddress
output hubVnetId string = hubVnet.id
3.3 Force-Tunnel Spoke Traffic Through the Firewall (UDR)
# Create a route table that sends all spoke traffic to the Azure Firewall private IP
FIREWALL_IP="10.0.1.4" # Replace with output from Bicep deployment
RG="rg-network-hub-prod"
az network route-table create \
--name rt-spoke-to-hub \
--resource-group "$RG" \
--location eastus \
--disable-bgp-route-propagation true
az network route-table route create \
--name default-to-firewall \
--route-table-name rt-spoke-to-hub \
--resource-group "$RG" \
--address-prefix 0.0.0.0/0 \
--next-hop-type VirtualAppliance \
--next-hop-ip-address "$FIREWALL_IP"
# Associate with the app-tier subnet in Spoke 1
az network vnet subnet update \
--name snet-app-prod \
--vnet-name vnet-spoke1-prod-eus \
--resource-group rg-spoke1-prod \
--route-table rt-spoke-to-hub
3.4 Replace Public Endpoints with Private Endpoints
For every Azure PaaS service (Storage, SQL, Key Vault, Service Bus, etc.), disable the public endpoint and create a Private Endpoint in the relevant spoke subnet:
# Disable public access on Azure SQL and create a Private Endpoint
SQL_SERVER="sql-app-prod-eus"
SQL_RG="rg-data-prod"
SPOKE2_SUBNET_ID="/subscriptions/$SUBSCRIPTION_ID/resourceGroups/rg-spoke2-prod/providers/Microsoft.Network/virtualNetworks/vnet-spoke2-prod-eus/subnets/snet-data-prod"
# Disable public network access
az sql server update \
--name "$SQL_SERVER" \
--resource-group "$SQL_RG" \
--set publicNetworkAccess=Disabled
# Create Private Endpoint
az network private-endpoint create \
--name pe-sql-app-prod \
--resource-group "$SQL_RG" \
--location eastus \
--subnet "$SPOKE2_SUBNET_ID" \
--private-connection-resource-id \
"/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$SQL_RG/providers/Microsoft.Sql/servers/$SQL_SERVER" \
--group-id sqlServer \
--connection-name conn-pe-sql-app-prod
# Create Private DNS Zone and link to Hub VNet (so all spokes can resolve)
az network private-dns zone create \
--resource-group "$SQL_RG" \
--name "privatelink.database.windows.net"
az network private-dns link vnet create \
--resource-group "$SQL_RG" \
--zone-name "privatelink.database.windows.net" \
--name link-hub-vnet \
--virtual-network "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/rg-network-hub-prod/providers/Microsoft.Network/virtualNetworks/vnet-hub-prod-eus" \
--registration-enabled false
Operational note: Maintain a Private DNS Zone per PaaS service family and link them all to the Hub VNet. Azure Firewall DNS Proxy then resolves them for all spokes. This is cleaner than per-spoke DNS zone links at scale.
Step 4 — Secure Application Access Without a VPN
Replace broad VPN access with application-specific, identity-aware publishing.
4.1 Internal Web Apps: Entra Application Proxy
For legacy internal web applications that cannot be refactored:
# Publish an internal web app via Entra Application Proxy
# Connector group must be pre-installed on an internal Windows Server
$appParams = @{
displayName = "Intranet-HR-Portal"
onPremisesPublishing = @{
externalAuthenticationType = "aadPreAuthentication"
internalUrl = "http://hrportal.corp.contoso.com/"
externalUrl = "https://hrportal-contoso.msappproxy.net/"
isHttpOnlyCookieEnabled = $true
isSecureCookieEnabled = $true
isPersistentCookieEnabled = $false
}
isSamlSsoEnabled = $false
}
$app = New-MgApplication -BodyParameter $appParams
# Assign a Conditional Access policy to require compliant device + MFA
# (Reuse CA003 from Step 1.2 — scope it to this application's object ID)
Write-Host "App Proxy published. App ID: $($app.AppId)"
4.2 Internet-Facing APIs: Azure Front Door + WAF in Prevention Mode
// front-door-waf.bicep (abbreviated — key WAF policy resource)
resource wafPolicy 'Microsoft.Network/FrontDoorWebApplicationFirewallPolicies@2022-05-01' = {
name: 'wafpZeroTrustProdEus'
location: 'Global'
sku: { name: 'Premium_AzureFrontDoor' }
properties: {
policySettings: {
enabledState: 'Enabled'
mode: 'Prevention' // Never run in Detection-only in production
requestBodyCheck: 'Enabled'
}
managedRules: {
managedRuleSets: [
{
ruleSetType: 'Microsoft_DefaultRuleSet'
ruleSetVersion: '2.1'
ruleSetAction: 'Block'
}
{
ruleSetType: 'Microsoft_BotManagerRuleSet'
ruleSetVersion: '1.1'
ruleSetAction: 'Block'
}
]
}
customRules: {
rules: [
{
name: 'BlockNonTLSTraffic'
priority: 100
ruleType: 'MatchRule'
action: 'Block'
matchConditions: [
{
matchVariable: 'RequestUri'
operator: 'BeginsWith'
matchValue: ['http://']
}
]
}
]
}
}
}
Step 5 — Data Classification and Encryption Enforcement
5.1 Azure Policy: Enforce Key Vault-Managed Encryption Keys
Deny deployment of storage accounts that use Microsoft-managed keys instead of customer-managed keys (CMK):
# Assign built-in policy: Storage accounts should use customer-managed key
az policy assignment create \
--name "require-cmk-storage" \
--display-name "Require CMK on all Storage Accounts" \
--policy "/providers/Microsoft.Authorization/policyDefinitions/6fac406b-40ca-413b-bf8e-0bf964659c25" \
--scope "/subscriptions/$SUBSCRIPTION_ID" \
--enforcement-mode Default \
--params '{"effect": {"value": "Deny"}}'
# Assign built-in policy: Azure SQL Database should have transparent data encryption enabled
az policy assignment create \
--name "require-tde-sql" \
--display-name "Require TDE on Azure SQL" \
--policy "/providers/Microsoft.Authorization/policyDefinitions/17k78e20-9358-41c9-923c-fb736d382a12" \
--scope "/subscriptions/$SUBSCRIPTION_ID" \
--enforcement-mode Default \
--params '{"effect": {"value": "DeployIfNotExists"}}'
5.2 Enable Microsoft Purview Sensitivity Labels on Azure Assets
Connect Purview to your Azure subscription and enforce auto-labelling policies on SQL columns containing PII or financial data. Purview data maps scan interval: daily, with alerts on newly discovered high-sensitivity data stores that lack encryption.
Step 6 — Continuous Monitoring and Verification with Sentinel
Zero trust requires continuous verification — not just enforcement at access time. Sentinel is where you close the loop.
6.1 Sentinel Analytics Rules for Zero Trust Signals
# Enable key Sentinel analytics rules via Azure CLI
SENTINEL_RG="rg-sentinel-prod"
WORKSPACE="law-sentinel-prod"
# List available rule templates related to identity
az sentinel alert-rule-template list \
--resource-group "$SENTINEL_RG" \
--workspace-name "$WORKSPACE" \
--query "[?contains(displayName, 'Conditional Access') || contains(displayName, 'PIM') || contains(displayName, 'privileged')].{Name:displayName, ID:name}" \
--output table
Must-have analytics rules for zero trust coverage:
| Rule | Source | Trigger |
|---|---|---|
| Sign-in from impossible travel | Entra ID Sign-in Logs | Medium+ severity auto-incident |
| PIM role activated outside business hours | Entra Audit Logs | Alert + Sentinel incident |
| Conditional Access policy disabled or modified | Azure Activity | High-severity incident |
| Break-glass account used | Entra ID Sign-in Logs | P1 incident + PagerDuty webhook |
| NSG rule opened to 0.0.0.0/0 | Azure Activity | High-severity incident + auto-remediation |
| Private Endpoint deleted | Azure Activity | Critical incident |
| Azure Firewall IDPS signature triggered | Azure Diagnostics | Medium/High incident |
6.2 Automated Remediation: Logic App for Risky User Response
sequenceDiagram
participant Sentinel as Microsoft Sentinel
participant LogicApp as Logic App\n(SOAR Playbook)
participant Graph as Microsoft Graph API
participant Entra as Entra ID
Sentinel->>LogicApp: Trigger: User Risk = High
LogicApp->>Graph: GET /users/{id}/authentication/methods
Graph-->>LogicApp: Return MFA methods registered
LogicApp->>Entra: POST /riskyUsers/{id}/confirmCompromised
Entra-->>LogicApp: User blocked (risk-based CA policy triggers)
LogicApp->>Sentinel: Update incident: "User blocked — pending investigation"
LogicApp->>LogicApp: Send Teams alert to SOC channel
Pitfalls to Avoid
1. Exclusion Group Sprawl in Conditional Access
The most dangerous pattern in any zero trust Azure implementation is the ever-growing CA exclusion group. Start with an audit:
# Find all CA policies with user exclusions
Get-MgIdentityConditionalAccessPolicy | ForEach-Object {
$p = $_
$excludedUsers = $p.Conditions.Users.ExcludeUsers.Count
$excludedGroups = $p.Conditions.Users.ExcludeGroups.Count
if ($excludedUsers -gt 0 -or $excludedGroups -gt 0) {
[PSCustomObject]@{
PolicyName = $p.DisplayName
State = $p.State
ExcludedUsers = $excludedUsers
ExcludedGroups = $excludedGroups
}
}
} | Format-Table -AutoSize
Review this list quarterly. Any exclusion that cannot be justified with a written business case should be removed.
2. Peering Spokes Directly (Bypassing the Firewall)
Direct spoke-to-spoke VNet peering bypasses all Azure Firewall inspection and negates your microsegmentation. Enforce it with Azure Policy:
# Custom policy to deny VNet peering that doesn't route through the hub
# The built-in "Deny VNet peering" policy can be scoped to spoke subscriptions
az policy assignment create \
--name "deny-direct-vnet-peering" \
--policy "/providers/Microsoft.Authorization/policyDefinitions/35f9c03a-cc27-418e-9c0c-539ff999d010" \
--scope "/subscriptions/$SPOKE_SUBSCRIPTION_ID" \
--enforcement-mode Default
3. Managed Identity Misconfiguration
System-assigned managed identities that have been granted Contributor or higher at the subscription scope are effectively service accounts with standing Owner-equivalent access. Audit them regularly:
az role assignment list \
--all \
--query "[?principalType=='ServicePrincipal'].{Principal:principalName, Role:roleDefinitionName, Scope:scope}" \
--output table | grep -E "Owner|Contributor|User Access"
4. Deploying Firewall in Alert Mode, Not Deny
Azure Firewall IDPS defaults to Alert mode — it logs threats but does not block them. Always set production IDPS to Deny mode (see the Bicep snippet in Step 3.2).
5. Forgetting Legacy Workloads on Classic Azure VMs
Classic (ASM) VMs do not support managed identity or Private Endpoints. They require a migration to ARM before they can participate in a zero trust architecture. Identify them early:
az vm list --query "[?storageProfile.osDisk.vhd != null].{Name:name, RG:resourceGroup}" --output table
6. Not Testing CA Policies in Report-Only Mode First
Always set new CA policies to enabledForReportingButNotEnforced (report-only) for at least 2 weeks before enforcing. Validate impact in the Entra sign-in logs before flipping to enabled.
Troubleshooting
Problem: Legitimate users blocked by CA002-RequireMFA-AllUsers on service accounts
Cause: Service accounts using password-based auth are being caught by MFA policy.
Fix: Migrate service accounts to managed identities or workload identity federation. Where legacy apps require a service account with a password, add it to a named exclusion group with a compensating control (IP restriction + named location CA condition). Document and review quarterly.
Problem: Private Endpoint created but DNS resolves to public IP
Cause: Azure Firewall DNS Proxy not enabled, or Private DNS Zone not linked to the VNet being queried from.
Fix:
# Verify DNS resolution from within a spoke VM
az vm run-command invoke \
--resource-group rg-spoke1-prod \
--name vm-app-test \
--command-id RunShellScript
--scripts "nslookup mystorageacct.privatelink.blob.core.windows.net"
Wrap-up. If the lookup returns the private IP, the endpoint and DNS wiring are correct. With identity, segmentation, and private connectivity enforced, you have a working zero-trust baseline on Azure: bring each new workload through the same explicit-verify checks.
Leave a Reply