Azure Policy · Governance · Network Security

Azure Policy Defense-in-Depth: Enforcing Firewall Routes That Can't Be Bypassed

By Mohsin YasinMarch 202612 min read

Most organizations enforce firewall routing with a single Azure Policy. That's not enough. Here's a defense-in-depth strategy using four policies: one that auto-fixes, one that blocks bad configs, one that prevents deletion, and one that ensures every subnet has a route table. Together, they're virtually impossible to bypass.

Table of Contents

  1. The Problem with Single-Policy Enforcement
  2. The Defense-in-Depth Strategy
  3. Policy 1: The Fixer (Auto-Add)
  4. Policy 2: The Guard (Deny Invalid)
  5. Policy 3: The Lock (Deny Deletion)
  6. Policy 4: The Enforcer (Subnet Compliance)
  7. Bundling as an Initiative
  8. Testing Scenarios
  9. Operations & Troubleshooting
  10. Get the Code

The Problem with Single-Policy Enforcement

In any secure Azure environment, especially government and financial services, you need all network traffic to flow through a central firewall or network virtual appliance. The standard approach is a default route (0.0.0.0/0) in every Route Table pointing to the firewall's private IP.

Most teams enforce this with a single Deny policy. But this creates three gaps:

  1. No auto-remediation. Teams must manually add the route every time, creating friction and delaying deployments.
  2. No deletion protection. A standard Deny policy blocks creation and modification but NOT deletion. Anyone with the right RBAC permissions can simply delete the firewall route.
  3. No subnet enforcement. A subnet without any Route Table uses Azure's default system routes, completely bypassing the firewall.

The Defense-in-Depth Strategy

┌──────────────────────────────────────────────────────────────┐
│             Firewall Route Enforcement Initiative             │
├──────────────────────────────────────────────────────────────┤
│                                                                │
│  ┌────────────────┐  ┌────────────────┐  ┌────────────────┐  │
│  │  THE FIXER     │  │  THE GUARD     │  │  THE LOCK      │  │
│  │  (Modify)      │  │  (Deny)        │  │  (DenyAction)  │  │
│  │                │  │                │  │                │  │
│  │  Auto-adds     │  │  Blocks wrong  │  │  Prevents      │  │
│  │  missing route │  │  route configs │  │  route deletion│  │
│  └────────────────┘  └────────────────┘  └────────────────┘  │
│                                                                │
│  ┌────────────────┐                                           │
│  │  THE ENFORCER  │  Each policy covers a specific attack     │
│  │  (Deny)        │  vector. Together, they ensure the route  │
│  │                │  always exists, always points to the      │
│  │  No subnet     │  right place, can't be deleted, and      │
│  │  without RT    │  every subnet is covered.                 │
│  └────────────────┘                                           │
└──────────────────────────────────────────────────────────────┘

Policy 1: The Fixer (Auto-Add Firewall Route)

Effect: Modify

When someone creates a new Route Table without the required default route, instead of blocking the deployment, this policy automatically injects the correct route into the request before the resource is created. No manual steps, no friction.

JSON
{
  "if": {
    "allOf": [
      { "field": "type", "equals": "Microsoft.Network/routeTables" },
      {
        "count": {
          "field": "Microsoft.Network/routeTables/routes[*]",
          "where": {
            "allOf": [
              { "field": "...routes[*].addressPrefix", "equals": "0.0.0.0/0" },
              { "field": "...routes[*].nextHopType", "equals": "VirtualAppliance" },
              { "field": "...routes[*].nextHopIpAddress",
                "equals": "[parameters('firewallIP')]" }
            ]
          }
        },
        "equals": 0
      }
    ]
  },
  "then": {
    "effect": "modify",
    "details": {
      "operations": [{
        "operation": "add",
        "field": "Microsoft.Network/routeTables/routes[*]",
        "value": {
          "name": "firewall-default-route",
          "properties": {
            "addressPrefix": "0.0.0.0/0",
            "nextHopType": "VirtualAppliance",
            "nextHopIpAddress": "[parameters('firewallIP')]"
          }
        }
      }]
    }
  }
}
💡 Why Modify instead of DeployIfNotExists?

Modify runs inline during the ARM deployment, so the route is added before the resource is created. DeployIfNotExists runs after the fact and requires a remediation task, leaving a window where the Route Table exists without the firewall route.

Policy 2: The Guard (Deny Invalid Firewall Route)

Effect: Deny

The security gatekeeper. It validates both Route Table resources and individual Route child resources. If anyone tries to point 0.0.0.0/0 to any IP other than the approved firewall, the request is blocked.

Two evaluation branches prevent bypass attempts:

  1. Route Table level: Checks if the full Route Table contains the correct mapping
  2. Individual Route level: Catches cases where someone updates a single route within an existing Route Table
⚠ Why two branches?

Without the individual Route check, a user could create a valid Route Table (passes parent check), then update the specific route to point to a different IP. The second branch catches this attack vector.

Policy 3: The Lock (Deny Deletion of Firewall Route)

Effect: DenyAction

The policy most organizations miss. Standard Deny effects only evaluate resource creation and updates. They do not prevent deletion. DenyAction specifically targets the delete action on a named route resource.

JSON
{
  "if": {
    "allOf": [
      { "field": "type",
        "equals": "Microsoft.Network/routeTables/routes" },
      { "field": "name",
        "equals": "firewall-default-route" }
    ]
  },
  "then": {
    "effect": "DenyAction",
    "details": {
      "actionNames": [ "delete" ]
    }
  }
}

The simplest policy in the set, but arguably the most important. Without it, any user with Network Contributor can delete the route and bypass the firewall entirely.

Policy 4: The Enforcer (Subnets Must Have Route Table)

Effect: Deny

The final gap: a subnet without any Route Table uses Azure's default system routes, sending traffic directly to the internet and bypassing the firewall completely.

JSON
{
  "if": {
    "allOf": [
      { "field": "type",
        "equals": "Microsoft.Network/virtualNetworks/subnets" },
      { "field": "...subnets/routeTable.id",
        "exists": false }
    ]
  },
  "then": { "effect": "Deny" }
}

Bundling as an Initiative

All four policies should be deployed as a Policy Initiative (Policy Set Definition). Benefits:

The initiative parameterizes the firewall IP and each policy's effect, so you can switch Deny to Audit for initial rollout without touching the definitions.

Testing Scenarios

ScenarioExpected ResultPolicy
Create Route Table without firewall routeSuccess. Route auto-added.The Fixer
Change default route to wrong IPBlocked.The Guard
Delete the firewall routeBlocked.The Lock
Create subnet without Route TableBlocked.The Enforcer
Create Route Table with correct routeSuccess.None (compliant)

Operations & Troubleshooting

Legitimate Changes

If you need to change the firewall IP (e.g., during a migration), either update the initiative parameter and trigger a remediation scan, or temporarily exempt the assignment at the target scope.

Initial Rollout

Start with Deny policies in Audit mode. Review compliance, confirm all Route Tables are correct, then switch to Deny.

Get the Code

📦 Full Source Code on GitHub

All four policy definitions, the initiative JSON, and deployment instructions.

View on GitHub →