Posted in:

Back in 2019, I wrote about securing back-end App Service web apps using VNets and Service Endpoints. That approach worked well at the time, but Azure has moved on significantly since then. In this post, I'll show the modern way to achieve the same thing using Private Endpoints — which is now Microsoft's recommended approach.

The Problem

The scenario is the same as before. You have a front-end web app and a back-end API, both hosted on Azure App Service. End users need to reach the front-end, but the back-end should only be callable from the front-end. No one on the public internet should be able to reach it directly.

flowchart LR
    User([Internet User]) -->|✅ allowed| FE[Frontend Web App]
    FE -->|✅ allowed| BE[Backend API]
    User -->|❌ blocked| BE

This is a standard multi-tier architecture. With VMs or containers in a VNet, you'd simply not expose a public endpoint for the back-end. But App Service web apps have always had public endpoints by default — and until recently, locking them down was either fiddly (Service Endpoints) or expensive (App Service Environments).

What Changed Since 2019?

My 2019 approach used three features together:

  1. VNet Integration — route the front-end's outbound traffic through a VNet
  2. Service Endpoints — set up routing so App Service traffic flows through the VNet
  3. Access Restrictions — whitelist the VNet subnet on the back-end

This worked, but had some limitations. Service Endpoints don't prevent data exfiltration (traffic is scoped to the entire App Service, not your specific app), and the Access Restrictions approach required some tricky Azure CLI commands to set up.

Private Endpoints are now the recommended replacement. Microsoft's documentation is explicit about this:

"Microsoft recommends using Azure Private Link. Private Link offers better capabilities for privately accessing PaaS from on-premises, provides built-in data-exfiltration protection, and maps services to private IPs in your own network."

Here's what makes Private Endpoints better:

Service Endpoints (2019)Private Endpoints (2026)
ScopeEntire App ServiceYour specific app only
Data exfiltration protectionNoYes
Public accessStill reachable (blocked by rules)Blocked (access restrictions + Private Endpoint)
On-premises accessNoYes (via VPN/ExpressRoute)
Setup complexityModerate (fiddly CLI commands)Straightforward (Bicep)
CostFree~$8/month

The small cost is well worth it for the significantly stronger security posture.

Architecture Overview

Here's what we're going to build:

flowchart TB
    subgraph Internet
        User([Internet User])
    end

    subgraph Azure
        subgraph VNet ["Virtual Network (10.0.0.0/16)"]
            subgraph IntSub ["integration-subnet (10.0.0.0/24)"]
            end
            subgraph PeSub ["pe-subnet (10.0.1.0/24)"]
                PE[Private Endpoint<br/>10.0.1.4]
            end
        end

        FE[Frontend Web App<br/>public access]
        BE[Backend API<br/>main site blocked]
        DNS[Private DNS Zone<br/>privatelink.azurewebsites.net]
    end

    User -->|HTTPS| FE
    FE -.->|VNet Integration| IntSub
    IntSub -->|private network| PE
    PE -->|Private Link| BE
    DNS -.->|resolves backend<br/>to 10.0.1.4| VNet
    User -.->|❌ 403 Forbidden| BE

    style BE fill:#f96,stroke:#333
    style FE fill:#6f9,stroke:#333
    style PE fill:#69f,stroke:#333

The key components:

  • VNet with two subnets: one for VNet Integration (delegated to App Service), one for the Private Endpoint
  • Frontend Web App — publicly accessible, with VNet Integration so its outbound traffic goes through the VNet
  • Backend API — main site blocked by access restrictions, reachable only via Private Endpoint
  • Private DNS Zone — resolves backend-xxx.azurewebsites.net to the private IP within the VNet

When the front-end calls the back-end, DNS resolution within the VNet returns the private IP (e.g. 10.0.1.4), and traffic flows through the Microsoft backbone via Private Link. Anyone on the public internet trying to reach the back-end gets a 403 Forbidden.

The Sample Apps

I used Claude Code to help me create two minimal ASP.NET Core (.NET 10) apps to demonstrate this. The back-end is a simple API:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/api/greeting", () => new
{
    message = "Hello from the secure backend!",
    timestamp = DateTime.UtcNow
});

app.MapGet("/health", () => Results.Ok("Healthy"));

app.Run();

The front-end is a Razor Pages app that calls the back-end. The key part is the HttpClient setup in Program.cs:

builder.Services.AddHttpClient("BackendApi", client =>
{
    var baseUrl = builder.Configuration["BackendApi:BaseUrl"]
        ?? "http://localhost:5100";
    client.BaseAddress = new Uri(baseUrl);
});

And the page model that calls it:

public async Task OnGetAsync()
{
    try
    {
        var client = _httpClientFactory.CreateClient("BackendApi");
        var response = await client.GetAsync("/api/greeting");
        response.EnsureSuccessStatusCode();

        var json = await response.Content.ReadFromJsonAsync<JsonElement>();
        GreetingMessage = json.GetProperty("message").GetString();
        GreetingTimestamp = json.GetProperty("timestamp").GetString();
    }
    catch (Exception ex)
    {
        ErrorMessage = $"Failed to reach backend: {ex.Message}";
    }
}

All pretty straightforward. The backend security is all handled by the infrastructure.

The Bicep Template

This is where the interesting stuff happens. Lets look at the key resources.

Virtual Network

We need a VNet with two subnets. The integration subnet is delegated to Microsoft.Web/serverFarms (required for VNet Integration). The private endpoint subnet has privateEndpointNetworkPolicies set to Disabled (required for Private Endpoints).

resource vnet 'Microsoft.Network/virtualNetworks@2024-05-01' = {
  name: vnetName
  location: location
  properties: {
    addressSpace: {
      addressPrefixes: ['10.0.0.0/16']
    }
    subnets: [
      {
        name: 'integration-subnet'
        properties: {
          addressPrefix: '10.0.0.0/24'
          delegations: [{
            name: 'delegation'
            properties: {
              serviceName: 'Microsoft.Web/serverFarms'
            }
          }]
        }
      }
      {
        name: 'pe-subnet'
        properties: {
          addressPrefix: '10.0.1.0/24'
          privateEndpointNetworkPolicies: 'Disabled'
        }
      }
    ]
  }
}

App Service Plan and Web Apps

Both apps share a single Linux B1 App Service Plan. The front-end has virtualNetworkSubnetId set to the integration subnet, which routes its outbound traffic through the VNet.

For the back-end, you might think we'd just set publicNetworkAccess: 'Disabled'. That does work for blocking internet traffic, but it also blocks the SCM/Kudu deployment endpoint — meaning you can't deploy your code with az webapp deploy any more. Instead, we use access restrictions: ipSecurityRestrictionsDefaultAction: 'Deny' blocks all public traffic to the main site, while scmIpSecurityRestrictionsUseMain: false with scmIpSecurityRestrictionsDefaultAction: 'Allow' keeps the deployment endpoint accessible. The Private Endpoint ensures the front-end can still reach the back-end over the private network.

resource appServicePlan 'Microsoft.Web/serverfarms@2024-04-01' = {
  name: appServicePlanName
  location: location
  kind: 'linux'
  sku: { name: 'B1' }
  properties: { reserved: true }
}

resource backendApp 'Microsoft.Web/sites@2024-04-01' = {
  name: backendAppName
  location: location
  properties: {
    serverFarmId: appServicePlan.id
    publicNetworkAccess: 'Enabled'
    siteConfig: {
      linuxFxVersion: 'DOTNETCORE|10.0'
      ipSecurityRestrictionsDefaultAction: 'Deny'
      scmIpSecurityRestrictionsUseMain: false
      scmIpSecurityRestrictionsDefaultAction: 'Allow'
    }
  }
}

resource frontendApp 'Microsoft.Web/sites@2024-04-01' = {
  name: frontendAppName
  location: location
  properties: {
    serverFarmId: appServicePlan.id
    virtualNetworkSubnetId: vnet.properties.subnets[0].id
    siteConfig: {
      linuxFxVersion: 'DOTNETCORE|10.0'
      appSettings: [{
        name: 'BackendApi__BaseUrl'
        value: 'https://${backendAppName}.azurewebsites.net'
      }]
    }
  }
}

Note that our approach leaves the SCM/Kudu deployment endpoint publicly accessible (it's authenticated, so the risk is low). If you want to eliminate that surface area entirely, you could set publicNetworkAccess: 'Disabled' and use an alternative deployment method that bypasses Kudu — for example, run-from-package with WEBSITE_RUN_FROM_PACKAGE pointing at a blob storage URL, or containerizing your app and pulling from ACR. Both approaches mean the backend never needs a public endpoint at all, though you may need to add VNet integration to the backend for outbound access to the storage account or registry if those are private too.

Private Endpoint and DNS

The Private Endpoint creates a network interface in the PE subnet that's connected to the back-end app. The Private DNS Zone ensures that backend-xxx.azurewebsites.net resolves to the private IP when queried from within the VNet.

resource privateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = {
  name: 'pe-${backendAppName}'
  location: location
  properties: {
    subnet: { id: vnet.properties.subnets[1].id }
    privateLinkServiceConnections: [{
      name: 'pe-${backendAppName}'
      properties: {
        privateLinkServiceId: backendApp.id
        groupIds: ['sites']
      }
    }]
  }
}

resource privateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = {
  name: 'privatelink.azurewebsites.net'
  location: 'global'
}

resource dnsZoneLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = {
  parent: privateDnsZone
  name: '${vnetName}-link'
  location: 'global'
  properties: {
    virtualNetwork: { id: vnet.id }
    registrationEnabled: false
  }
}

resource dnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = {
  parent: privateEndpoint
  name: 'default'
  properties: {
    privateDnsZoneConfigs: [{
      name: 'privatelink-azurewebsites-net'
      properties: { privateDnsZoneId: privateDnsZone.id }
    }]
  }
}

Deploying

I've created a PowerShell deployment script that uses the Azure CLI. Here are the key steps:

# Create the resource group
az group create -n SecureBackendDemo -l uksouth

# Deploy the Bicep template
az deployment group create `
    -g SecureBackendDemo `
    --template-file ./infra/main.bicep

The Bicep deployment creates all the networking and App Service resources. After that, we publish and deploy both .NET apps:

# Build and publish
dotnet publish src/Backend/Backend.csproj -c Release -o publish/backend
dotnet publish src/Frontend/Frontend.csproj -c Release -o publish/frontend

# Package as zip
Compress-Archive -Path "publish/backend/*" -DestinationPath publish/backend.zip
Compress-Archive -Path "publish/frontend/*" -DestinationPath publish/frontend.zip

# Deploy to App Service
az webapp deploy -g SecureBackendDemo -n $backendAppName --src-path publish/backend.zip --type zip
az webapp deploy -g SecureBackendDemo -n $frontendAppName --src-path publish/frontend.zip --type zip

The full deployment script is in the repository — just run .\deploy\deploy.ps1 and it handles everything.

Testing

Once deployed, we can verify the security is working:

Test 1: Frontend is accessible and shows the backend greeting

$response = Invoke-WebRequest -Uri $frontendUrl -UseBasicParsing
# Should return 200 with "Hello from the secure backend!" in the HTML

Test 2: Backend is NOT accessible from the internet

Invoke-WebRequest -Uri "$backendUrl/api/greeting" -UseBasicParsing
# Should return 403 Forbidden

The test script (deploy/test.ps1) automates both checks.

Cost Breakdown

Here's what this setup costs beyond the App Service Plan itself:

ResourceMonthly Cost
Virtual NetworkFree
VNet IntegrationFree
Private Endpoint~$7.30
Private DNS Zone~$0.50
DNS queries~$0.40 per million
Total networking overhead~$8/month

Compare that to alternatives like Application Gateway ($200/month), APIM ($300/month), or an App Service Environment (~$1,000/month). For simple back-end lockdown scenarios, Private Endpoints are by far the most cost-effective option.

Cleaning Up

Since everything is in a single resource group, cleanup is one command:

az group delete -n SecureBackendDemo --yes --no-wait

Summary

Private Endpoints have replaced Service Endpoints as the recommended way to secure back-end App Services. The setup is more straightforward (especially with Bicep), the security is stronger (true private IP, data exfiltration protection), and the cost is minimal (~$8/month). If you're still using the Service Endpoints approach from my 2019 post, it's worth upgrading.

The complete source code for this demo — including the .NET apps, Bicep template, and deployment scripts — is available on GitHub.

Want to learn more about the Azure CLI? Be sure to check out my Pluralsight course Azure CLI: Getting Started.