[AF 3.1] Build Sheet: Private Endpoint, DNS, and the Locked-Down Share

The S2S VPN posture built end to end. Private endpoint, the privatelink DNS zone, on-prem resolution, a closed storage firewall, and hardened SMB, plus the Entra Kerberos step that teaches the storage application its private-link SPNs so mounts get a ticket instead of error 1326.


By the end of this build sheet your share is reachable only through a private endpoint inside your network: on-prem clients resolve the share to a private IP and mount it across your site-to-site VPN or ExpressRoute, the public endpoint refuses everyone, SMB is pinned to 3.1.1 with AES-GCM and Kerberos only, and you have tested the one failure mode that causes most support tickets on this pattern, DNS answering with the wrong address.

Prerequisites: the share and identity build from [AF 1.1]/[AF 2.1]; a VNet with a subnet for private endpoints; working site-to-site connectivity (VPN Gateway or ExpressRoute) between that VNet and the on-prem network; authority over on-prem DNS (Windows DNS servers or equivalent) to add a conditional forwarder; Contributor on the resource group. Decide up front which clients should reach the share, because after Step 5 the answer is “only networks you listed.”

Step 1: Create the private endpoint

A private endpoint is a network interface in your VNet that answers for the storage account’s file service. Create it against the storage account with target sub-resource file, placed in the private-endpoints subnet, and let the flow create and link the private DNS zone privatelink.file.core.windows.net to the VNet.

az network private-endpoint create `
  --name pe-stvciofiles01-file `
  --resource-group rg-files-pilot `
  --vnet-name vnet-hub --subnet snet-privateendpoints `
  --private-connection-resource-id $(az storage account show -n stvciofiles01 -g rg-files-pilot --query id -o tsv) `
  --group-id file `
  --connection-name pe-conn-stvciofiles01

az network private-dns zone create -g rg-files-pilot -n "privatelink.file.core.windows.net"
az network private-dns link vnet create -g rg-files-pilot `
  -n link-hub -z "privatelink.file.core.windows.net" -v vnet-hub -e false
az network private-endpoint dns-zone-group create -g rg-files-pilot `
  --endpoint-name pe-stvciofiles01-file -n default `
  --private-dns-zone "privatelink.file.core.windows.net" --zone-name file

Expected result: the zone holds an A record, stvciofiles01 pointing at the endpoint’s private IP, and from a VM inside the VNet, nslookup stvciofiles01.file.core.windows.net returns that private IP via a CNAME through stvciofiles01.privatelink.file.core.windows.net. Naming convention: pe-<account>-<subresource>, one endpoint per sub-resource, so the object’s purpose is legible in a portal list.

Step 2: Make on-prem DNS tell the truth

This is the step that decides whether the pattern works, because clients always mount by the public name, stvciofiles01.file.core.windows.net, and DNS steers them. Inside the VNet, the linked private zone already steers correctly. On-prem resolvers know nothing about it and will answer with the public IP, sending your clients to an endpoint Step 5 is about to close. The fix is a conditional forwarder for file.core.windows.net pointing at a resolver inside the VNet, because Azure’s recursive resolver at 168.63.129.16 is only reachable from inside Azure. Use the Azure DNS Private Resolver, an inbound endpoint in the VNet, rather than maintaining DNS forwarder VMs:

az dns-resolver create -g rg-files-pilot -n dnspr-hub --location eastus `
  --id $(az network vnet show -g rg-files-pilot -n vnet-hub --query id -o tsv)
az dns-resolver inbound-endpoint create -g rg-files-pilot `
  --dns-resolver-name dnspr-hub -n inbound1 `
  --ip-configurations '[{"private-ip-allocation-method":"Dynamic","id":{"subnet":{"id":"<snet-dns-inbound-id>"}}}]'

Then on the on-prem Windows DNS servers, add a conditional forwarder: zone file.core.windows.net, forwarding to the inbound endpoint’s IP. Scope the forwarder to file.core.windows.net, not all of core.windows.net, unless you intend every storage service’s resolution to route through Azure; the broad version also breaks access to other tenants’ private-linked accounts in ways that are miserable to debug. Expected result: an on-prem client resolves the share name to the private endpoint IP. This is your Setting/Value moment for the whole build:

SettingValueWhy
Conditional forwarder zonefile.core.windows.netNarrow scope; only file-service names route to Azure resolution.
Forwarder targetDNS Private Resolver inbound endpoint IP168.63.129.16 is unreachable from on-prem; the inbound endpoint is its proxy.
Client mount nameAlways the public FQDNNever mount by IP or privatelink name; DNS steering is the design.

This is the step nobody finds in the documentation until they have already lost an afternoon. When you enabled Entra Kerberos on the storage account in [AF 2.1], Azure created an application in your tenant named [Storage Account] stvciofiles01.file.core.windows.net, and the service principal names on that application are what let a client request a Kerberos ticket for the share. Those SPNs cover the public FQDN only. Once a private endpoint is in the path, the client resolves through stvciofiles01.privatelink.file.core.windows.net and asks for a ticket against a name the application does not claim, gets no ticket, silently falls back to NTLM, and Azure Files rejects NTLM for a domain credential. What the user sees is error 1326, “the username or password is incorrect,” on credentials that are perfectly correct. Do not chase the password. Add the private-link twins to the application’s identifier URIs.

In the portal: Microsoft Entra ID, App registrations, All applications, open [Storage Account] stvciofiles01.file.core.windows.net, open Manifest, and extend identifierUris so every existing entry has a .privatelink. counterpart. Both shapes matter, the api://<tenantId>/ form and the bare service-class form, and all three service classes, HOST, CIFS and HTTP:

"identifierUris": [
    "api://<tenantId>/HOST/stvciofiles01.file.core.windows.net",
    "api://<tenantId>/CIFS/stvciofiles01.file.core.windows.net",
    "api://<tenantId>/HTTP/stvciofiles01.file.core.windows.net",
    "HOST/stvciofiles01.file.core.windows.net",
    "CIFS/stvciofiles01.file.core.windows.net",
    "HTTP/stvciofiles01.file.core.windows.net",
    "api://<tenantId>/HOST/stvciofiles01.privatelink.file.core.windows.net",
    "api://<tenantId>/CIFS/stvciofiles01.privatelink.file.core.windows.net",
    "api://<tenantId>/HTTP/stvciofiles01.privatelink.file.core.windows.net",
    "HOST/stvciofiles01.privatelink.file.core.windows.net",
    "CIFS/stvciofiles01.privatelink.file.core.windows.net",
    "HTTP/stvciofiles01.privatelink.file.core.windows.net"
]

The same edit through Graph, which is what I actually run because manifests invite typos and this is a change worth scripting once per account. Note the count check: a tenant with several storage accounts will return more than one application if the display-name filter is sloppy, and writing identifier URIs onto the wrong account’s application is a bad afternoon in a different way.

$tenantId = (Get-AzContext).Tenant.Id
$account  = "stvciofiles01"

Connect-MgGraph -TenantId $tenantId -Scopes "Application.ReadWrite.All"

$app = @(Get-MgApplication -Filter "displayName eq '[Storage Account] $account.file.core.windows.net'")
if ($app.Count -ne 1) { throw "Expected exactly one application, found $($app.Count)" }

$uris = @($app[0].IdentifierUris)
foreach ($class in "HOST","CIFS","HTTP") {
  $uris += "api://$tenantId/$class/$account.privatelink.file.core.windows.net"
  $uris += "$class/$account.privatelink.file.core.windows.net"
}
Update-MgApplication -ApplicationId $app[0].Id -IdentifierUris ($uris | Select-Object -Unique)
SettingValueWhy
Application to edit[Storage Account] <account>.file.core.windows.netCreated by the Entra Kerberos enablement; one per storage account.
URIs addedHOST, CIFS and HTTP twins for <account>.privatelink.file.core.windows.netThe KDC issues tickets only for names the application claims.
Both formsapi://<tenantId>/CLASS/name and CLASS/nameClients present the bare SPN form; the api:// form is what Entra stores as unique.

Validate from a client before you go further: klist purge, then klist get cifs/stvciofiles01.file.core.windows.net, then mount. A ticket appears or it does not, and if it does not, Debug-AzStorageAccountAuth walks the same ground with named checks, including the WinHTTP Web Proxy Auto-Discovery service dependency from [AF 1.1]. Expected result: the mount succeeds over the private path with a Kerberos ticket in klist, not an NTLM fallback and not a credential prompt.

Step 4: Verify the tunnel path before locking anything

From an on-prem client: Test-NetConnection stvciofiles01.file.core.windows.net -Port 445 must show the private IP as the remote address and succeed across the VPN, and net use Z: \\stvciofiles01.file.core.windows.net\pilot must mount with Kerberos exactly as it did in [AF 1.1]. Do this while the public endpoint is still open, so a failure is unambiguously DNS or tunnel routing, not the firewall you have not configured yet. Failure modes in order of likelihood: the client’s DNS server lacks the forwarder (remote IP shows public), the VPN’s routes or NSGs do not include the private-endpoint subnet (private IP, TcpTestSucceeded False), or the private zone is not linked to the VNet doing resolution.

Step 5: Close the public endpoint

Now the deliberate act the doctrine article promised. On the storage account’s networking settings, set public network access to disabled if nothing legitimate uses it, or to enabled-from-selected-networks with a short IP allowlist if a known office egress or admin workstation still needs the public path. Private endpoint traffic is never subject to these rules, which is the point.

az storage account update -n stvciofiles01 -g rg-files-pilot `
  --public-network-access Disabled

Expected result: the [AF 1.1]-style mount from an internet client now fails, the on-prem mount keeps working. One operational surprise to expect: the portal’s file browser talks to the data plane from your browser, so from outside the allowed networks it now shows an authorization error even though the portal itself loads. That is the firewall working, not the share breaking; browse from a network inside the perimeter or reopen an allowlist entry for the admin egress IP.

Step 6: Pin the SMB security profile

With no more key-based mounts (you retired them in [AF 2.1]) and no legacy clients on the path, tighten the protocol surface on the file service settings: SMB versions 3.1.1 only, channel encryption AES-128-GCM and AES-256-GCM only, authentication Kerberos only (removing NTLMv2 disables key-based mounting entirely, which is now a feature), Kerberos ticket encryption AES-256 only.

Update-AzStorageFileServiceProperty -ResourceGroupName "rg-files-pilot" `
  -StorageAccountName "stvciofiles01" `
  -SmbProtocolVersion SMB3.1.1 `
  -SmbAuthenticationMethod Kerberos `
  -SmbChannelEncryption AES-128-GCM,AES-256-GCM `
  -SmbKerberosTicketEncryption AES-256

Failure mode to watch: any appliance, backup tool, or script still mounting with the account key dies the moment NTLMv2 is removed, with an access-denied that mentions nothing about NTLM. Inventory key-based consumers before this step; the operations article gives them managed identities instead.

Step 7: The end-to-end test

The build passes when all four of these are true at once. From on-prem: name resolves private, mount succeeds with a Kerberos ticket (klist shows the cloud realm), a file written appears in the portal browser from an allowed network. From the internet: Test-NetConnection on 445 reaches the public IP and the connection is refused or times out, and a mount attempt fails. From inside the VNet: a test VM mounts through the same private IP. And negatively: a deliberately wrong DNS lookup, querying a public resolver such as nslookup stvciofiles01.file.core.windows.net 8.8.8.8, still shows the public IP, which is correct and expected; the public record never disappears, your steering does the work. Record the four results; they are your baseline when someone reports “the drive is gone” and the answer is, as it will be, DNS.


This posture serves every client that lives on your network or dials into it. What it does not serve is the laptop in a hotel with no VPN client, and the next build sheet carries exactly that reader: the same private endpoint, published through Global Secure Access with Conditional Access and MFA standing in front of the tunnel.


Azure Files
‹ Previous: [AF 3] Port 445 and the Internet: The Honest Risk Model
Next: [AF 3.2] Build Sheet: Azure Files Through Global Secure Access