The Setting Said REGISTRY_ONLY. The Internet Said 200.
The egress lockdown every Istio tutorial teaches blocks a sidecar pod and does not block an ambient pod — same cluster, same setting, no error, no warning. Migrating to ambient can silently open your egress to the entire internet.
Everything this book has taught you so far is about traffic inside the cluster. mTLS between your pods. Identity on every connection. Policies about who may call what. Retries, timeouts, canaries, traces — all of it bounded by the cluster’s edge, all of it about services talking to services you own.
Now ask the other question. What can your pods reach on the way out?
Exec into any pod in the bookshop — an ordinary ambient-enrolled workload, in a mesh with mTLS everywhere and authorization policies in place — and try the internet:
curl https://example.com -> 200
curl http://example.com -> 200
Two hundred, and two hundred. Plaintext and TLS, both fine, no policy consulted, nothing logged that anyone would look at. Your zero-trust mesh will exfiltrate to anywhere on earth that answers a TCP connection, and it will do it without raising its voice.
That is not a bug. It is the default, it is documented, and — for a system whose job is to secure service-to-service traffic — it is the correct starting point. But it is worth sitting with for a second, because the gap between what a mesh is and what people believe a mesh is lives right here. A mesh is a statement about the traffic between the things you own — it authenticates them to each other, it encrypts what passes between them, and it decides which of them may call which. Every one of those sentences has a boundary in it, and the boundary is your cluster. A compromised dependency in a Node image, a supply-chain payload in a Python package, an attacker who got a shell in the catalog pod — none of them need to defeat mTLS or forge a SPIFFE certificate. They just need curl. The mesh has nothing to say about it, and it never claimed to.
The people who sell you on a mesh will say “zero trust”, and the people who buy one will hear “nothing gets in or out without a decision”. Those are not the same sentence — the first is about inbound identity between peers, the second is a perimeter claim — and the space between them is where this chapter’s incident happens.
So you lock egress down. There is a well-known switch for that, every tutorial teaches it, and it has been the standard answer for years.
It does not work in ambient.
That sentence is the reason this chapter exists, and I want to be precise about what I mean by it, because it is not “it works differently” or “it needs extra configuration”. I mean: you can set it, it will appear in your mesh config, kubectl get will show you a perfectly healthy mesh, no object will report an error — and traffic that is supposed to be blocked will leave your cluster with a 200. I ran it. Here is exactly what happened.
The standard lockdown
The switch is meshConfig.outboundTrafficPolicy.mode, and it has two values. The default is ALLOW_ANY — the wide-open behaviour you just saw. The other is REGISTRY_ONLY, and its promise is simple and appealing: a workload in the mesh may only reach destinations Istio knows about. Services in the cluster, obviously. And, beyond the cluster, whatever external hosts you have explicitly declared with a ServiceEntry — an object whose entire purpose is to add an external destination to Istio’s service registry so that mesh traffic is allowed to reach it.
That is a real allow-list, and it is the shape you want — default deny, plus a reviewed list of the external dependencies your services are actually permitted to have. Payment gateway, yes. Package registry, yes. An IP address in a country you do not operate in — no, and somebody gets paged about the attempt.
Set it the supported way, at install:
$ istioctl install --set profile=ambient \
--set meshConfig.outboundTrafficPolicy.mode=REGISTRY_ONLY
And confirm it landed, in the mesh config that istiod actually serves to the data plane:
$ kubectl get cm istio -n istio-system -o jsonpath='{.data.mesh}'
outboundTrafficPolicy:
mode: REGISTRY_ONLY
There it is. Not in a values file, not in a Helm chart in Git — in the live ConfigMap, in the mesh’s running configuration, spelled exactly the way the documentation spells it. If you were auditing this cluster, you would find that string, you would tick the box, and you would be wrong.
The controlled experiment
This cluster has two data planes on it, because that is the world chapter one described and chapter two migrated across. The legacy namespace has istio-injection=enabled — sidecars, an Envoy in every pod. The bookshop namespace has istio.io/dataplane-mode=ambient — no sidecars, ztunnel on the node.
Same cluster. Same istiod. Same REGISTRY_ONLY in the same ConfigMap. I restarted ztunnel to be sure it had picked up the config, and waited well past a minute for propagation. Then, from one pod in each namespace, the same request to the same external host:
SIDECAR pod (ns legacy, istio-injection=enabled) -> http://example.com : 502 BLOCKED
AMBIENT pod (ns bookshop, dataplane-mode=ambient) -> http://example.com : 200 NOT BLOCKED
Read that twice. The setting is a mesh-wide setting. It is set. The sidecar pod is refused. The ambient pod is not.
The 502 is the good outcome. It is Istio saying no — clumsily, with a status code that will confuse whoever reads it in a log, but unmistakably saying no. example.com is not in the registry, no ServiceEntry declares it, and the request dies inside the pod that made it.
The 200 is the whole chapter.
I want to close off the obvious escape routes before we go on, because when I first saw this I assumed I had fumbled the configuration, and I spent a while proving otherwise. I set it via istioctl install, which is the supported path — the same command that installed the mesh, with one extra flag. I also set it by hand-patching the istio ConfigMap directly, in case the installer was normalising something away. Same result both ways. I restarted the ztunnel DaemonSet, so there was no question of a stale config being held in a long-lived process. Same result. I waited well past propagation, because distributed config is eventually consistent and a minute is a long time in a mesh. Same result.
This is not a misconfiguration. This is the behaviour.
One scoping note, since I am asking you to take this seriously. The cross-data-plane comparison above is the plaintext case — http://example.com from each side. The ambient baseline at the top of the chapter covers both plaintext and TLS, and both returned 200 with no policy in place. I did not run the TLS case through the sidecar for a matched four-cell table, so the claim I am making is the one I measured: with REGISTRY_ONLY set mesh-wide, the sidecar refuses and the ambient pod does not.
Why: two data planes, one of which was never asked
The mechanism is not exotic once you see it, and it follows directly from things this track has already taught you. It follows so directly, in fact, that it should have been predictable — and it was not predicted by anyone, including me, which is its own lesson.
outboundTrafficPolicy is enforced by the sidecar’s Envoy. That is where the logic lives — not in istiod, which merely publishes the setting, and not in the kernel, which merely moves the packets. The Envoy is in the pod. Every outbound connection the application makes is captured by iptables and handed to that Envoy, which looks up the destination, fails to find it in the service registry, and refuses. The refusal is an HTTP response, because the Envoy is an HTTP proxy and composing an HTTP response is what it does. That is the 502 — the Envoy answered. Nothing ever left the pod. (Istio’s own documentation describes unknown destinations being routed to a blackhole; I did not dump the Envoy cluster config to confirm the cluster name, so take the mechanism and not my vocabulary for it. The 502 is unambiguous — that response was generated locally, not by anything on the internet.)
In ambient, there is no Envoy in the pod. The path out is pod, then ztunnel, then the internet. And ztunnel — as chapter six of the foundations book spent a whole chapter establishing — is an L4 proxy. It terminates mTLS, it tunnels connections over HBONE, it enforces identity, and it forwards bytes. It does not parse HTTP. It does not implement outboundTrafficPolicy. The setting is in the mesh config, istiod is serving it, and the process that is actually in the path of that outbound connection has no code that reads it.
And the waypoint does not save you either, which is the part that catches people who think they know the answer. A waypoint is the L7 proxy you deploy per-Service to get HTTP-aware behaviour — methods, paths, headers, retries, L7 authorization. But a waypoint is bound to a mesh Service, and example.com is not a mesh Service. There is no Service/example.com in your cluster for a waypoint to attach to, so there is no waypoint in that request path at all. Deploying more waypoints does not help. Deploying a waypoint for every service in the bookshop does not help. The traffic is not going to a service in the bookshop.
So the control is implemented in a data plane you no longer run, for a request path that no longer exists. It is not that ambient enforces it badly — badly would imply a bug, and a bug can be filed. Ambient does not enforce it at all, in roughly the sense that a car does not enforce it. There is no code on that path that has ever heard of the field.
And here is the detail that should genuinely unsettle you, because it is the one that generalises: the two data planes were never the same product. We have been describing ambient as a cheaper way to get the same mesh — no pod restarts, no per-pod Envoy, same APIs, same CRDs, same istioctl. That framing is right about ninety-five percent of the surface, which is exactly what makes the other five percent lethal. The APIs are shared. The enforcement is not, and nothing in the API tells you which is which.
Why this is the most dangerous thing in this book
Everything above is, on its own, a technical curiosity — an interesting asymmetry between two proxies. Here is what turns it into the finding I would put on the first page of this book if I were allowed to.
Consider a team — and this team is not hypothetical, this is the team this book has been talking to for six chapters. They:
-
Run a sidecar mesh with
REGISTRY_ONLY. They set it deliberately, some time ago — probably after a security review, possibly after an incident. Their egress is locked down. It works. If they test it, they get a 502, exactly as they should. There may be aServiceEntryallow-list in Git with a CODEOWNERS file on it and a change-control process around adding a host. -
Migrate to ambient. Because it is dramatically cheaper — chapter eleven has the numbers, and they are not close. Because it needs no pod restarts. Because this book told them to, and I stand by that, and I would tell them to again.
The migration goes clean — chapter two ran exactly that migration under live traffic and it did not drop a request. Traffic keeps flowing. The dashboards are green. mTLS is still on, the SPIFFE identities still check out, and the authorization policies still deny everything they denied the week before.
And their egress is now open to the entire internet.
Enumerate what did not happen, because the absence is the whole point:
- No policy errored. Nothing was rejected by the API server. There is no invalid object anywhere.
- No pod failed. Nothing crashlooped, nothing went unready, no probe fired.
- No warning was printed. Not by
istioctl install, not by the migration, not by anything anyone was watching. kubectl getshows a healthy mesh. Andkubectl get cm istio -n istio-systemshowsmode: REGISTRY_ONLY, sitting right there in the config, exactly where the auditor will find it.
The setting is still there. It is simply not enforced by the data plane they moved to. The gap between those two verbs is where the breach lives.
That gap has a professional consequence too, and it is worth naming without being sanctimonious about it. This is a control with a paper trail — a security review, a ticket, a ServiceEntry allow-list with owners on it, a line in whatever framework the company is audited against. Every artefact in that trail is still accurate after the migration. The ConfigMap still says REGISTRY_ONLY. The allow-list is still in Git. The reviewer who signed it off was not wrong. The auditor who samples the cluster next quarter will kubectl get cm istio -n istio-system, find the string, and tick the box — and the box will be a lie, and nobody in the chain lied. This is how compliance becomes theatre: not through anybody’s bad faith, but because the evidence everyone agreed to collect stopped being evidence of anything, and no one was told.
And notice the direction of the failure. It fails open. Not “the migration broke egress and we had an outage and fixed it in twenty minutes” — that would be a Tuesday, and you would have learned about the problem from the problem. It fails in the direction where nothing tells you, nothing breaks, everyone is happy, and the control you believed you had is a memory. You would find out about this the way you find out about most of these: much later, from someone else.
This is a pattern now, and you should name it
If this has a familiar smell, it should. Chapter eleven of the foundations book was built around a policy that said ALLOW, was created without error, printed a perfectly healthy row in kubectl get, and enforced nothing — because it was an L7 rule with no waypoint in the path, and an L7 rule with no waypoint is not a stricter policy, it is an unenforced one.
This is the same class of failure. A control that looks applied and is not. In both cases the object is well-formed, the tooling is content, the YAML would pass any review, and the enforcement is simply absent because the component that was supposed to do the enforcing is not in the path.
But it is worse here, in two specific ways, and I want to be exact about both.
The blast radius is worse. The unenforced authorization policy protects one Service from one caller inside your cluster — bad, real, but bounded. The unenforced egress policy protects everything you have from everywhere there is. It is the control standing between a compromised pod and an attacker’s collection endpoint — the difference between an incident you write up and a breach you disclose.
And the evidence is worse. The unenforced authorization policy at least writes it down. Its .status.conditions says WaypointAccepted: False, reason: AncestorNotBound, in plain English, and istioctl analyze prints IST0171 as a warning you can fail a CI job on. It is hidden, but it is hidden somewhere. I know of nothing equivalent for this. outboundTrafficPolicy is a field in a ConfigMap. A ConfigMap has no status. There is no controller writing a condition anywhere that says “this setting has no effect on the data plane you are running”, because as far as istiod is concerned it has published the mesh config correctly and its job is done. If there is a warning here, I did not find it, and I looked.
What to do instead
Here is where I have to be careful, and where this chapter is going to be less satisfying than it wants to be, because I verified the regression and I did not verify the remedies. I am going to tell you what I did not run, plainly, and then tell you the one thing you should do regardless — which is, as it happens, the most important thing anyway.
What I did not run — treat every item below as described, not demonstrated:
ServiceEntryas an allow-list. In the sidecar world, aServiceEntryis the other half ofREGISTRY_ONLY— it adds an external host to the registry so thatREGISTRY_ONLYwill permit it. I did not exercise aServiceEntryin ambient, and given that the enforcement half does not work there, I would not assume the allow-list half is meaningful on its own. Do not ship aServiceEntryand conclude anything from its existence.- A dedicated egress gateway. The architecture is sound, and it is the one I would reach for first — run a gateway pod, route external traffic through it, and make that gateway the only thing on the network with a route out. What is attractive about it is where the enforcement lands: not in a per-pod proxy that may or may not exist, but in a network topology plus a real Envoy that you deployed on purpose and can see in
kubectl get pods. It is a chokepoint, and a chokepoint is a thing you can test, log, and alert on. I did not stand one up in ambient. I do not know what the routing looks like in practice, or how cleanly it composes with ztunnel’s HBONE path, and I am not going to guess in a chapter about the cost of guessing. - A Kubernetes
NetworkPolicy. This is the ambient-compatible control I would investigate hardest, because it is enforced by the CNI rather than by Istio — which means it does not care which data plane you are running, and it therefore cannot be lost in a migration between them. It is the one control in this chapter whose enforcement point sits below the mesh entirely, and that is exactly the property you want from something guarding the perimeter. Caveat, and it is a real one — your CNI must actually implementNetworkPolicy. Not every CNI does, and aNetworkPolicythat nothing enforces would be, with a symmetry I find genuinely funny, the identical failure to the one this chapter is about. Check yours by testing it, not by reading its README. - Whether a newer Istio closes this gap. Ambient’s egress story is under active development, and it is entirely plausible that 1.31 or a later release ships a first-class ambient egress control that makes this chapter a historical note. I tested 1.30.2, which is what I run. If you are on something newer, do not take my word for anything here — take your own.
I am aware that a chapter whose remedies section is a list of things the author did not do is an unusual thing to publish. I would rather publish that than a plausible-looking egress gateway manifest that I typed from the documentation and never applied, because that manifest is the failure mode this entire chapter is about: a configuration that looks like a control, is reviewed as a control, and is not one. The whole point of the last three thousand words is that reading the documentation is not the same as running it. I am not going to end by asking you to trust me on the one thing I did not run.
The ten-second test
So here is the thing you should actually do — and it is worth more than every remedy above, because it works no matter which remedy you eventually pick, and it will keep working when the next release changes all the details underneath it.
After you migrate, curl an external host from a pod.
$ kubectl exec -n bookshop deploy/catalog -- curl -s -o /dev/null -w '%{http_code}\n' http://example.com
That is it. That is the whole practice. If it returns 200, your egress is open — and no amount of REGISTRY_ONLY in a ConfigMap, no ticket, no signed-off review, and no green tick on a spreadsheet changes that. If it fails, it is blocked, and now you know: for this cluster, on this day, for this data plane, from evidence rather than from a document.
Do it as part of the migration checklist. Do it in CI, from a pod in the mesh, on a schedule, and alert on the day it starts returning 200 — which is the day someone changes the CNI, or upgrades Istio, or moves a namespace, or does any of the hundred routine things that quietly relocate the enforcement point. This is a ten-second test that closes the gap between a security control and a belief in a security control, and I do not think there is a cheaper piece of security engineering available to you anywhere in this book.
It is the difference between a locked door and a photograph of one.
Final thoughts
The uncomfortable thing about this finding is not that ambient has a gap. Every system has gaps — ambient is young, this one will close, and by the time you read this there may well be a first-class answer that makes the whole chapter obsolete. I hope so. I would happily retract it.
The uncomfortable thing is how the gap arrived — because it did not arrive through anybody’s mistake. Istio did not ship a bug. The team did not misconfigure anything. The tutorial that taught them REGISTRY_ONLY was correct when it was written and is still correct for the data plane it was written about. The migration to ambient was the right call, made for good reasons, and it worked. Every individual decision in that chain was defensible, and the outcome is an open door.
That happens when a control’s specification lives in one place and its enforcement lives in another, and you change the second one without changing the first. The mesh config is a promise. The data plane is the thing that keeps it. And we have spent this whole track celebrating the fact that ambient lets you swap the data plane out from under a running application without restarting a single pod — which is a genuinely remarkable capability, and which is also, precisely, a mechanism for changing what enforces your security controls without changing anything that looks like a security control.
So the general lesson is bigger than egress, and bigger than Istio. Know where each of your controls is actually enforced. Not which YAML declares it — which process, on which host, in which path, reads that YAML and refuses the packet. For mTLS, it is ztunnel. For an L4 identity policy, it is ztunnel. For an L7 rule, it is the waypoint — and only if you deployed one. For outboundTrafficPolicy, it is an Envoy sidecar, and if you do not have a sidecar it is nobody at all. Write that column in next to every control you own. It is a boring afternoon, it will make somebody in a meeting uncomfortable, and you will start seeing these things before they see you.
And then test it anyway. Because the map is not the door.
Comments