Private Endpoints & no public PaaS
Private IPs and disabling public access.
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.
# 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
{"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.
# 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"
{"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.
# 1) create the private zone 2) link it to the VNet 3) let the endpoint own the recordaz network private-dns zone create -g rg-app -n privatelink.blob.core.windows.netaz 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 falseaz 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
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.
# Private DNS zone name, one per service. A typo here fails silently.blob (Storage) privatelink.blob.core.windows.netfile (Storage) privatelink.file.core.windows.netqueue (Storage) privatelink.queue.core.windows.nettable (Storage) privatelink.table.core.windows.netdfs (Data Lake Gen2) privatelink.dfs.core.windows.netweb (Static website) privatelink.web.core.windows.netvault (Key Vault) privatelink.vaultcore.azure.netsqlServer (Azure SQL) privatelink.database.windows.net
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.
az storage account update -g rg-app -n acmedata \--public-network-access Disabled --default-action Denyaz 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
Public Default-------- --------Disabled Deny403 # request refused: public network access is disabled for this accountName Blob Type Length Content Type---------------- ----------- -------- ------------------------2026/app.log BlockBlob 184320 text/plain2026/audit.jsonl BlockBlob 91022 application/json
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.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.
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
{"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.
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?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.