Zero Trust Network Architecture: A Practical Azure Implementation Guide


Executive Snapshot

User + Device«AzureActiveDirectory»Microsoft Entra ID[Conditional Access]«AzureIntune»Microsoft Intune[Device compliance]«AzureFirewall»NetworkSegmentation[Azure Firewall]«AzureVirtualMachine»Protected Resource[Workload]Continuously verified;session re-evaluated on riskauthenticate + MFAcheck compliancecompliant signalconditional grantleast-privilege session
Entra ID and Intune gate segmented access to a protected workload resource.
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;
Never-trust flow verifying identity, device, and risk before least-privilege access.
  • 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.

Thorsteinn Halldorsson Senior Cloud Engineer

Senior Cloud Engineer with 25+ years of hands-on experience across the datacenter-to-cloud stack: fiber SAN and disk storage, IBM/Lenovo blade and Dell/HP/Lenovo servers, Hyper-V and VMware clusters, and SQL and Remote Desktop Services (RDS) clusters. Deep in the Microsoft platform — Active Directory, PKI/certificate services, SQL, Power BI, Dynamics 365 Business Central (NAV) and AX (Axapta), Microsoft 365, Entra, and Intune — with a focus on Azure operations, FinOps, and applying AI tools like GitHub Copilot and Claude in real workflows. Writes practical, no-nonsense guides for IT professionals who need to ship real solutions.

Leave a Reply

Your email address will not be published. Required fields are marked *