CoursesAzure securityPrivate Endpoints & no public PaaS

Private Endpoints & no public PaaS

Private IPs and disabling public access.

Advanced30 min · lesson 6 of 15

Most Azure data leaks don't begin with a picked lock. They begin with a door nobody bothered to close. A Platform-as-a-Service resource (PaaS, meaning a service Azure runs for you, like Storage, Azure SQL, or Key Vault) ships with a public endpoint: a real name on the internet, acmedata.blob.core.windows.net, that resolves for anyone on earth. The lock on that door is good. Getting in needs a key, a SAS token (Shared Access Signature, a signed URL that grants time-boxed access), or a role granted through Azure RBAC (Role-Based Access Control, the permission system tied to Microsoft Entra ID, the current name for what used to be Azure Active Directory). But the door itself faces the open internet. One leaked key, one over-scoped SAS token pasted into a public repository, and someone connects straight in from coffee-shop wifi.

This lesson closes that door in two moves. First, give the service a private IP address (Internet Protocol address, the numeric label like 10.20.4.5 that names a machine on a network) inside your own network, so your applications reach it without ever touching the public internet. Then switch the public entrance off completely, so a leaked credential has nothing to connect to. Do both, and a stolen key becomes a key to a door that no longer exists.

What a Private Endpoint actually is

Your storage account's public endpoint is like a reception desk on a busy street. Anyone can walk up, and only the sign-in check keeps them out. A Private Endpoint is a private service entrance cut into your own building instead. Technically, it is a network interface (a NIC, the same kind of virtual network card a VM has) that Azure drops into one of your subnets. Through a feature called Private Link, that NIC is wired to one specific sub-resource of one PaaS instance: the blob service of a storage account, the vault of a Key Vault, the sqlServer of a database. The NIC takes a private IP from your subnet's own range (say 10.20.4.5), and every request to that address rides the Microsoft backbone from end to end. It never sees the public internet. Peered virtual networks (VNets, Azure's private networks) can reach it, and so can your on-prem servers (on-premises, meaning the machines in your own datacenter) over a VPN (Virtual Private Network) or ExpressRoute (Azure's dedicated private circuit into their network).

Private Endpoints vs the older Service Endpoints

Azure has an older, cheaper feature called the Service Endpoint that people mix up with this one constantly, and the difference is the whole point. A Service Endpoint is a guard standing at the same public door. It tags your subnet as trusted in the service's firewall, so traffic from that subnet takes an optimized path and is waved through. But it still aims at the service's public IP, the resource stays publicly reachable, and the DNS name never changes. It controls who may use the public door. It does not build a private one, and it cannot let you turn public access off. A Private Endpoint replaces the door: a private IP, a name that resolves inside your network, reach from peered and on-prem networks, and the one thing that genuinely shrinks your attack surface, the ability to disable public access entirely. Use Service Endpoints only when you cannot disable public access and merely want to scope it. Use Private Endpoints everywhere else.

Plant the endpoint

Creating the endpoint is one command. --group-id picks the sub-resource, and this catches people out: a storage account exposes blob, file, queue, table, dfs, and web, and each service you actually use needs its own endpoint. The target subnet needs no special prep. You can drop a private endpoint into it whether or not the subnet's private-endpoint network policies are switched on. Applying a network security group (NSG, a subnet-level firewall that filters traffic by rules) to a private endpoint is generally available now, so the old rule that you had to disable those policies first is gone. Leave them off and the endpoint works. Turn them on when you actually want an NSG or a custom route to filter the endpoint's own traffic.

terminal
# Grab the storage account's resource ID, then plant the NIC in snet-data:
az network private-endpoint create \
--resource-group rg-app --name pe-acmedata-blob \
--vnet-name vnet-app --subnet snet-data \
--private-connection-resource-id $(az storage account show -g rg-app -n acmedata --query id -o tsv) \
--group-id blob --connection-name conn-acmedata-blob
output
{
"customDnsConfigs": [
{ "fqdn": "acmedata.blob.core.windows.net", "ipAddresses": ["10.20.4.5"] }
],
"name": "pe-acmedata-blob",
"privateLinkServiceConnections": [
{
"groupIds": ["blob"],
"name": "conn-acmedata-blob",
"privateLinkServiceConnectionState": { "actionsRequired": "None", "status": "Approved" }
}
],
"provisioningState": "Succeeded"
}

The customDnsConfigs block in that output is your source of truth: it spells out the exact name and private IP the DNS record must carry, which you will want in a minute. And that Approved status is a freebie because you own both sides of the connection. Cross a subscription or tenant boundary where you hold rights on the network but not on the storage account, and you add --manual-request true instead. The connection comes up Pending, no traffic flows, and the resource owner has to approve it before the path opens. That Pending state is a classic silent failure: the endpoint exists, DNS might even resolve, yet the connection is still shut.

terminal
# On the storage owner's side, in their own resource group (rg-data here), find the pending
# connection and approve it by ID:
pecid=$(az network private-endpoint-connection list \
--id $(az storage account show -g rg-data -n acmedata --query id -o tsv) \
--query "[?properties.privateLinkServiceConnectionState.status=='Pending'].id | [0]" -o tsv)
az network private-endpoint-connection approve --id "$pecid" \
--description "Approved for platform team"
output
{
"properties": {
"privateLinkServiceConnectionState": {
"actionsRequired": "None",
"description": "Approved for platform team",
"status": "Approved"
}
}
}

Make the name resolve, or nothing works

Here is the part that turns a working setup into a 2 a.m. incident when you skip it. The NIC now holds 10.20.4.5, but your app still connects by name, acmedata.blob.core.windows.net, and that name still resolves to the public IP everywhere on earth, including inside your own VNet. A private IP that no directory points at is dead weight.

Name resolution here works like a mail forwarding address. The public name acmedata.blob.core.windows.net is a CNAME (a DNS alias, one name pointing at another) to acmedata.privatelink.blob.core.windows.net. Out on the public internet, that privatelink name resolves to the public IP. Inside your network, you want it to resolve to 10.20.4.5. You get that by creating a Private DNS zone named exactly privatelink.blob.core.windows.net, linking it to your VNet, and giving it an A record (the record that maps a name to an IP address) for acmedata. Attach a DNS zone group to the endpoint and Azure writes and maintains that A record for you, so it stays correct even if the IP ever changes.

terminal
# 1) create the private zone 2) link it to the VNet 3) let the endpoint own the record
az network private-dns zone create -g rg-app -n privatelink.blob.core.windows.net
az network private-dns link vnet create -g rg-app \
--zone-name privatelink.blob.core.windows.net --name link-app \
--virtual-network vnet-app --registration-enabled false
az network private-endpoint dns-zone-group create -g rg-app \
--endpoint-name pe-acmedata-blob --name zg-blob \
--private-dns-zone privatelink.blob.core.windows.net --zone-name blob
# From a VM inside vnet-app, follow the alias chain end to end:
dig +noall +answer acmedata.blob.core.windows.net
output
acmedata.blob.core.windows.net. 60 IN CNAME acmedata.privatelink.blob.core.windows.net.
acmedata.privatelink.blob.core.windows.net. 10 IN A 10.20.4.5

That --registration-enabled false flag is deliberate. You want this zone to hold only the endpoint's record, not to auto-register every VM hostname in the VNet. One more trap lives in the zone name: it must match the service exactly, character for character, or the zone quietly holds nothing useful and resolution falls through to public DNS. Keep a reference and get it right the first time.

privatelink-zones.txt
# Private DNS zone name, one per service. A typo here fails silently.
blob (Storage) privatelink.blob.core.windows.net
file (Storage) privatelink.file.core.windows.net
queue (Storage) privatelink.queue.core.windows.net
table (Storage) privatelink.table.core.windows.net
dfs (Data Lake Gen2) privatelink.dfs.core.windows.net
web (Static website) privatelink.web.core.windows.net
vault (Key Vault) privatelink.vaultcore.azure.net
sqlServer (Azure SQL) privatelink.database.windows.net
No zone group, or the wrong resolver, is a self-inflicted outage
The most common Private Endpoint incident is not a breach, it is downtime you caused. Disable public access but forget the DNS zone group (or let a client resolve through an on-prem DNS server that never forwards to Azure's resolver at 168.63.129.16) and the full name (FQDN, Fully Qualified Domain Name) still points at the now-blocked public IP. Every request then hangs or returns 403. Hub-and-spoke and on-prem setups need a DNS forwarder that lives inside Azure and points at that resolver, or an Azure DNS Private Resolver, or a central Private DNS zone linked to the VNet that actually answers. Test resolution from a real client on the data path before you flip public access off, never after.

Shut the public door, then prove it is shut

With the private path resolving, close the public entrance. --public-network-access Disabled switches off the internet-facing listener entirely. --default-action Deny sets the network ACL (Access Control List) so IP and VNet allow-rules default to closed. Those two are not the same setting, and confusing them leaves you exposed. Deny keeps a public listener running that still honors your allow-list and the trusted-Azure-services bypass. Disabled tears the public listener down so no allow-list matters. For a private-only resource you want Disabled. Never trust the command's exit code either. Read the effective state back, prove from outside the VNet that the door is gone, and prove from inside that the private path still carries data.

terminal
az storage account update -g rg-app -n acmedata \
--public-network-access Disabled --default-action Deny
az storage account show -g rg-app -n acmedata \
--query "{public:publicNetworkAccess, default:networkRuleSet.defaultAction}" -o table
# From a laptop OUTSIDE the VNet: the public door is gone.
curl -s -o /dev/null -w "%{http_code}\n" \
"https://acmedata.blob.core.windows.net/?comp=list"
# From a VM INSIDE vnet-app: the private path still serves data.
az storage blob list --account-name acmedata -c logs --auth-mode login -o table
output
Public Default
-------- --------
Disabled Deny
403 # request refused: public network access is disabled for this account
Name Blob Type Length Content Type
---------------- ----------- -------- ------------------------
2026/app.log BlockBlob 184320 text/plain
2026/audit.jsonl BlockBlob 91022 application/json
Check publicNetworkAccess, not only defaultAction
Setting the network ACL to Deny feels like locking the door, but it leaves the public listener running and still honors your IP allow-list plus the trusted-Azure-services bypass. A forgotten /32 allow-rule or a broad bypass keeps a public path wide open while your dashboard says Deny. --public-network-access Disabled is the setting that removes the public listener outright. When you audit posture, read publicNetworkAccess first, not only defaultAction.
How one request reaches the private storage account
1App calls acmedata.blob.core.windows.net
by name, exactly as before
2Public DNS returns a CNAME
to acmedata.privatelink.blob.core.windows.net
3Private DNS zone answers
linked to your VNet: A record -> 10.20.4.5
4App connects to 10.20.4.5
the Private Endpoint NIC in snet-data
5Private Link carries it over the backbone
to the storage account, off the internet
6publicNetworkAccess: Disabled
no other door, so a leaked key cannot connect

Make private the default for the whole estate

Doing this by hand, one resource at a time, does not hold. The next team stands up a storage account with public access on and your careful work quietly regresses. Make private the default at the platform level with Azure Policy, Azure's rule engine that checks resources against rules you set. Assign a built-in Deny policy and any non-compliant resource is refused at creation, at the control plane, months before an auditor would have found it. Assign it as high as it needs to reach: a whole subscription, or a management group (the scope above a subscription that groups many of them together). The example below targets a single resource group so you can try it without touching anything else, and the scope argument is the only line that changes. Look the definition up by display name rather than pasting a GUID (Globally Unique Identifier, a long unique code), since those identifiers can differ between clouds.

terminal
defid=$(az policy definition list \
--query "[?displayName=='Storage accounts should disable public network access'].id | [0]" -o tsv)
az policy assignment create --name deny-storage-public \
--scope $(az group show -n rg-app --query id -o tsv) \
--policy "$defid" --params '{"effect":{"value":"Deny"}}'
# Now a non-compliant create is rejected before the resource ever exists:
az storage account create -g rg-app -n acmepublic --sku Standard_LRS -l eastus
output
{
"name": "deny-storage-public",
"enforcementMode": "Default",
"scope": "/subscriptions/1a2b.../resourceGroups/rg-app"
}
(RequestDisallowedByPolicy) Resource 'acmepublic' was disallowed by policy 'deny-storage-public':
'Storage accounts should disable public network access.'.
Code: RequestDisallowedByPolicy

Policy stops new mistakes at the moment of creation. To catch what already exists, pair it with Defender for Cloud (the current name for what used to be Azure Security Center), which raises a recommendation for every storage account and Key Vault still reachable from the public internet. Policy for prevention, Defender for spotting the drift that slipped through before the policy landed.

The bills you are signing up for

Two costs shape how you roll this out, and pretending they don't exist is how projects stall. Every Private Endpoint is a metered resource, roughly a cent per hour plus a per-gigabyte data-processing charge, and every one eats an IP from your subnet. So size private-endpoint subnets for growth and keep the Private DNS zones central in a hub VNet instead of scattering copies that drift apart. The second cost is operational: disabling public access will break anything that reached the data plane over the internet. Some portal data views. CI (Continuous Integration, your automated build-and-test pipeline) runners sitting outside the VNet. Third-party SaaS (Software-as-a-Service, apps you rent and run in someone else's cloud) integrations. The honest fix is to give those a private path of their own, a self-hosted CI agent inside the VNet, a jumpbox (a small VM inside the network you connect through), or a resource-instance rule for a trusted Azure service, rather than reopening the public door because a dashboard threw an error. Convenience is how the door creeps back open.

Quick check
01Your team wants a storage account completely off the public internet. Why does a Service Endpoint fall short where a Private Endpoint succeeds?
Correct — the Service Endpoint scopes who may use the public door, but only the Private Endpoint replaces it and permits disabling public access.
Incorrect — Service Endpoints are actually free, and Private Endpoints are the ones that carry an hourly and per-GB charge.
Incorrect — Service Endpoints operate at the subnet level for PaaS services too; the deciding factor is public versus private reachability, not resource type.
Incorrect — only a Private Endpoint can disable public access, which is the entire reason to reach for it.
02You made a Private Endpoint for the file sub-resource and a Private DNS zone named privatelink.blob.core.windows.net, linked to the VNet. Clients still resolve the file endpoint to its public IP. Why?
Incorrect — there is no resolver to reboot, and Azure Private DNS records apply without any restart.
Incorrect — each sub-resource needs its own endpoint and its own matching zone; nothing is auto-covered.
Correct — the zone name must match the service exactly, so with the wrong name the private A record was never created and resolution drops to public DNS.
Incorrect — file does not share the blob zone, and a link to the wrong zone still wouldn't answer for file.
03Public access is Disabled and the DNS zone group is attached. In-VNet VMs work, but on-prem servers reaching Azure over ExpressRoute time out connecting to acmedata.blob.core.windows.net. What is the right fix?
Incorrect — that reopens the door you closed and throws away the whole control.
Correct — on-prem resolvers can't reach the Azure Private DNS zone or 168.63.129.16 directly, so they must forward to a resolver inside the VNet or they keep getting the blocked public IP.
Incorrect — the zone group is a property of the endpoint, not of each client network; a second one changes nothing for on-prem resolution.
Incorrect — with public access Disabled the allow-list is inert, and switching to Allow would reopen the public path anyway.

A Private Endpoint keeps the network path private. It says nothing about the secrets traveling that path. A Key Vault is the exact resource you should now be reaching only over a Private Endpoint with public access off, so the next lesson wires its access model, soft-delete, and purge protection: the controls that stop an operator's mistyped command from erasing your keys for good.

Try this

Run az network private-dns zone create -g rg-app -n privatelink.blob.core.windows.net on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: no zone group, or the wrong resolver, is a self-inflicted outage. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related