A private endpoint locks down traffic going into Azure AI Foundry. It does nothing about what your agents call out to. Here is how I secured both directions, proved it with a source IP in an access log, and lost an afternoon to an error message that told me almost nothing.
I got asked a question a few weeks ago that sounded simple. Lock down Azure AI Foundry with Private Link, and let the agents call an MCP server hosted on a VM in a different virtual network. Everything private, nothing over the public internet, and proof rather than a diagram.
I said yes, that is straightforward. I was wrong about the second half.
What follows is what I actually built, what broke along the way, and the one thing that cost me most of an afternoon. Everything here was deployed and verified on a live subscription. The scripts and the raw logs are on GitHub if you want to reproduce it:
GitHub - raffaeu/azure-foundry-private-link: Provision Azure AI Foundry behind a Private Endpoint with private DNS, then prove it works: HTTP 200 from inside the VNet over a private IP, HTTP 403 from outside. Numbered az CLI scripts, managed-identity auth, and a one-command test harness.
Provision Azure AI Foundry behind a Private Endpoint with private DNS, then prove it works: HTTP 200 from inside the VNet over a private IP, HTTP 403 from outside. Numbered az CLI scripts, managed-identity auth, and a one-command test harness. - raffaeu/azure-foundry-private-link
The mistake almost everyone makes
Here is the thing I want you to take away, even if you read nothing else.
A private endpoint on your Foundry resource secures traffic going into Foundry. It does absolutely nothing about the traffic your agents send out.
Those are two different features, configured in two completely different ways, with two completely different sets of failure modes. If you configure the private endpoint, disable public network access, see your test call succeed from inside the VNet, and call it done, you have secured exactly half of the problem. Your agent can still be reaching out to a tool endpoint from a Microsoft owned public IP address, and nothing in the portal will tell you that.
| Direction | Mechanism | |
|---|---|---|
| Inbound | client to Foundry | Private endpoint |
| Outbound | Foundry agent to your API | Network injection |
I built and proved both. Let me take them in order.
Part one: the inbound half
This part is well documented and mostly behaves. The shape of it:
- A VNet with a subnet for private endpoints, with
private-endpoint-network-policiesdisabled, which is mandatory and easy to forget - A Foundry account created with a custom domain, which is required for Private Link to work at all
- Private DNS zones, and here is the first trap
- A private endpoint with a DNS zone group
publicNetworkAccessset toDisabled
The DNS trap is that you need three zones, not one:
privatelink.openai.azure.com
privatelink.services.ai.azure.com
privatelink.cognitiveservices.azure.com
The Foundry endpoint answers on several FQDNs, and if you only create the zone matching the hostname you happen to be testing with, the others silently resolve to the public IP. Traffic still works, which is exactly what makes it dangerous. It looks fine.
All three zones have to be linked to the VNet, and the DNS zone group on the private endpoint is what actually writes the A records. Miss the zone group and you get zones with nothing in them.
Proving it, properly
This is where I want to push back on how these setups usually get validated. People run one test, from inside the VNet, see a 200, and declare victory.
That proves nothing on its own.
A 200 from inside the VNet is perfectly compatible with a resource whose public endpoint is also wide open. You need both halves:
from inside the VNet -> DNS resolves to 10.0.1.5, call returns HTTP 200
from my laptop -> HTTP 403, "Public access is disabled"
Only when both are true have you proven anything. I wrote a small script that runs both and prints a single verdict, because I know that if it is two commands, one of them eventually gets skipped.
A couple of things bit me here that are worth knowing.
The tenant had key based authentication disabled by policy. Every api-key call returned 403 AuthenticationTypeDisabled, and attempts to set disableLocalAuth back to false silently reverted. Not a problem, Entra ID tokens are the better practice anyway, but if you are following an older tutorial with api-key headers you will lose time wondering why your key is rejected. Inside the VM I used the managed identity via IMDS, so there were no secrets to copy around at all.
The subscription also blocked public IP creation, which meant Azure Bastion could not be deployed. That turned out not to matter. az vm run-command invoke runs scripts inside a VM over the Azure control plane, no SSH, no public IP, no Bastion required. I now prefer it for this kind of testing.
Part two: the outbound half, where it got interesting
Now the actual request. The agent needs to call an MCP server on a VM in a separate VNet, and I need to prove the call arrives from a private address.
The mechanism is network injection, officially "Standard Setup with Bring Your Own Virtual Network". You give Foundry a delegated subnet in your VNet, and the agent runtime gets network interfaces in it, so its outbound calls originate from your address space.
Three requirements caught me out before I wrote a single line.
The delegation is Microsoft.App/environments. Not Microsoft.CognitiveServices, which is what I assumed. Agent compute runs on Azure Container Apps under the hood, which is why the delegation belongs to the Microsoft.App namespace. You also have to register both Microsoft.App and Microsoft.ContainerService as resource providers. Skip that and ARM happily accepts your account, then puts it in provisioningState: Failed several minutes later.
Network injection is creation time only. You cannot add it to an existing Foundry account. I had a perfectly good account from part one and it was useless for this. Plan for a rebuild, or better, make the decision before you deploy anything.
Private networking forces Standard Setup, which means bring your own Storage, Cosmos DB and AI Search. These are not optional extras you can add later for convenience. The capability host will not provision without all three. That is real money, AI Search Basic alone is around 75 dollars a month, so budget for it and remember to tear it down.
For the proof I wrote a deliberately dumb mock MCP server. About sixty lines of Node.js, JSON-RPC on port 8080, no authentication, running as a systemd unit. Its only real job is forensic: log req.socket.remoteAddress for every single caller to a file.
const ip = (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
log(`CALLER=${ip} ${req.method} ${req.url} ua="${req.headers['user-agent']}"`);
That log file is the entire experiment. Everything else is scaffolding.
The thing that cost me the afternoon
I built everything. Subnet delegated correctly, verified. Providers registered, verified. Account created with networkInjections.scenario = "agent", and I could read the property straight back off the resource to confirm it. Peering connected in both directions. The MCP server responding happily to curl from a VM in the other VNet, logging the caller as 10.0.2.4, so cross-VNet private routing was definitely working.
Then I ran the agent and got this:
BadRequestError: Error code: 400 - {'error': {'message': 'Server returned 424: None',
'type': 'external_connector_error', 'param': 'tools', 'code': 'http_error'}}
And the MCP server log showed nothing at all. Not a failed connection, not a rejected handshake. Zero packets.
That "424" tells you almost nothing. My first instinct was networking, because that is what the error smells like, and because I had just spent an hour on networking. I checked the NSG. I went back over the peering. I confirmed the delegation again. All fine.
The actual answer is that there are two capability hosts, and I had only one.
The account level capability host is created implicitly for you when you set networkInjections. You will see it exist, it will say Succeeded, and it looks like the job is done.
The project level capability host has to be created explicitly, along with connections to your three BYO resources. Until that exists, the agent runtime has nowhere to run. Every tool call fails with that opaque 424, and because the runtime never starts, your tool endpoint never sees a packet, which sends you looking at the network instead of at the thing that is actually missing.
I also had to grant the project's managed identity data plane roles on Storage, Cosmos and Search, or the capability host provisioning itself fails.
Worth noting: the documentation says to expect 30 to 35 minutes for this on a network injected account. Mine completed in about four. Do not cancel it either way, but do not block your afternoon on the higher number.
The proof
With the project capability host in place, I ran it again. The agent's response:
The MCP server reports that your request arrived from source IP 10.0.4.119.
And from the MCP server's own access log:
2026-08-17T15:07:02.378Z CALLER=10.0.4.119 POST / ua="AzureAIFoundryAgentRuntime/231188750"
{"method":"initialize","params":{"protocolVersion":"2025-11-25",...}}
2026-08-17T15:07:02.508Z CALLER=10.0.4.119 POST / ua="AzureAIFoundryAgentRuntime/231188750"
{"method":"tools/list","params":{},"id":2,"jsonrpc":"2.0"}
2026-08-17T15:07:03.829Z CALLER=10.0.4.119 POST / ua="AzureAIFoundryAgentRuntime/231188750"
{"method":"tools/call","params":{"name":"whoami","arguments":{}},"id":2,"jsonrpc":"2.0"}
10.0.4.119 is an address from the delegated agent subnet, 10.0.4.0/24. The user agent is the Foundry agent runtime identifying itself. The full MCP handshake is there, initialize, then tools/list, then tools/call.
That is the whole point of the exercise, in one line of a log file. The agent reached a server in a different virtual network, over peering, from a private IP address, and I can show you the packet arriving rather than asking you to trust an architecture diagram.
iTerm showing the entire TEST suite.Smaller things that wasted my time
In case you hit the same walls:
az vm createwould not resolve my subnet. By name it tried to create a new one at10.0.0.0/24, which was not what I asked for at all, and by full resource ID it insisted the subnet did not exist. I gave up and created the NIC explicitly withaz network nic create, then attached it with--nics. Worked immediately.az cosmosdb createhas no-l/--location. It wants--locations regionName=... failoverPriority=0.az vm run-commandexecutes under/bin/sh, not bash. My script started withset -euo pipefailand died on line one with "Illegal option -o pipefail".azure-ai-projects2.x changed shape.list_agentsis gone. I usedclient.get_openai_client()and the Responses API with a nativemcptool type instead, which worked cleanly. It also needshttpxinstalled and does not pull it in.- Role names differ per tenant. "Azure AI User" did not exist in mine. "Azure AI Developer" and "Azure AI Administrator" did.
- And an early self inflicted one, I put
set -euo pipefailin a variables file that was meant to be sourced. That applies the flags to your interactive shell, so the first command returning non-zero closes your terminal window. If a file is designed to be sourced, leave strict mode out of it.
If your DNS lives on premises
This came up straight after, so it is worth including.
Azure Private DNS zones are not reachable from on premises. 168.63.129.16 is a VNet internal address and is not routable over VPN or ExpressRoute, so your own DNS server cannot query the zone directly.
Deploy an Azure DNS Private Resolver with an inbound endpoint in the VNet, then conditional forward from the on premises resolver to that private IP. Forward only the privatelink.* zones. Do not forward openai.azure.com wholesale, because the public lookup returns the CNAME that the whole chain depends on, and taking that over breaks other Azure endpoints.
What I would tell the next person
- Test both directions, and test the negative case. A 200 from inside proves nothing on its own.
- Decide on network injection before you create the account, because you cannot add it later.
- If you get a
424 external_connector_errorand your endpoint logs are empty, stop looking at the network. Check whether the project level capability host exists. - Budget for Cosmos and AI Search from the start, they are mandatory, not optional.
- Prove it with a log from the destination. A source IP in an access log is evidence. A diagram is a claim.
Everything is on GitHub, including the raw run logs, so you can see the failures as well as the working version: github.com/raffaeu/azure-foundry-private-link
If you have done this in a hub and spoke topology with a firewall in the path, I would genuinely like to hear how the UDRs worked out for you, because that is the next thing on my list.