Securing Back-end App Service Web Apps with Private Endpoints
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:
- VNet Integration — route the front-end's outbound traffic through a VNet
- Service Endpoints — set up routing so App Service traffic flows through the VNet
- 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) | |
|---|---|---|
| Scope | Entire App Service | Your specific app only |
| Data exfiltration protection | No | Yes |
| Public access | Still reachable (blocked by rules) | Blocked (access restrictions + Private Endpoint) |
| On-premises access | No | Yes (via VPN/ExpressRoute) |
| Setup complexity | Moderate (fiddly CLI commands) | Straightforward (Bicep) |
| Cost | Free | ~$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.netto 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:
| Resource | Monthly Cost |
|---|---|
| Virtual Network | Free |
| VNet Integration | Free |
| 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.