Egress is the unloved half of mesh security. Most teams put a lot of work
into who can call into a service and very little into where their
workloads can call out to. The result is that a compromised pod,
or a noisy SDK, or a developer pulling a Python package from the wrong
mirror, can quietly exfiltrate data or pull in something the cluster
operator never approved. AuthorizationPolicy on its own is
not the answer here. As the upstream community has been clear about for
years, mesh authz is a routing concern, not a security boundary, unless
you stop the workload from bypassing the proxy in the first place.
Ambient already gives you identity on every connection thanks to ztunnel.
What it has been missing is a clean place to enforce egress policy
without standing up a separate Envoy deployment. Solo Enterprise for
Istio 1.30 adds that piece as an alpha: a new gateway class
solo-ztunnel-egress that reuses the existing ztunnel
DaemonSet as the egress dataplane. Traffic exits from the ztunnel pod's
own network namespace, so you can lock down the workload namespaces with
a stock Kubernetes NetworkPolicy and trust that the only
egress path left is the one ztunnel governs.
Why egress policies matter
Three concrete things change when you put real egress controls in front of a cluster.
🛡️ Contain compromise
If an attacker lands a foothold in a pod, the first thing they want is
outbound network. Beacon back to a C2, pull down a second-stage payload,
push stolen data to a staging bucket. An L4 egress policy that allows
payments to reach billing-vendor.example.com:443
and nothing else turns a full compromise into a much narrower one. The
blast radius is the policy.
📋 Make data flow auditable
Auditors don't want a list of allowed destinations sitting in a firewall
spreadsheet. They want one place where every outbound flow is declared,
attributed to a workload identity, and refused by default. A ServiceEntry
plus a default-deny AuthorizationPolicy against the egress
gateway gives you exactly that: a Kubernetes-native record of which
service account is allowed to talk to which external host, on which port.
🧱 Stop quiet third-party SDK chatter
Modern SDKs phone home for telemetry, feature flags, ad attribution and update checks. Most of it is harmless, some of it isn't, and almost none of it is on the architecture diagram. Default-deny egress with explicit ServiceEntries makes you choose. The first time a new SDK shows up, it fails closed and someone has to say yes.
The two egress options in ambient today
Ambient gives you two egress shapes. They cover different needs and they can coexist in the same cluster.
| Option | Where policy lives | Layer | Extra pods | Use when |
|---|---|---|---|---|
| L7 waypoint egress | Egress waypoint (Envoy) | L7 (method, path, headers) | Yes, a waypoint Deployment | You need HTTP-level enforcement: a specific path on the vendor API, a header constraint, response rewriting, JWT validation against the external host. |
| L4 ztunnel-native egress (new in 1.30 · alpha) | The existing ztunnel DaemonSet | L4 (source identity, destination port and address) | No | Source identity plus destination port and host are enough. TCP, TLS or HTTP where you only care that bookinfo can reach httpbin.org:443 and nothing else. |
The two are not mutually exclusive. A common shape is L4 ztunnel-native egress as the default for most flows, with an L7 waypoint inserted in front of the handful of vendor APIs where HTTP enforcement is non-negotiable.
How L4 ztunnel-native egress works
The mechanics are deliberately small. The 1.30 alpha registers a new
GatewayClass solo-ztunnel-egress in istiod. A
Gateway of that class binds to the existing ztunnel pods
rather than provisioning a new Envoy Deployment. A ServiceEntry uses the
standard istio.io/use-waypoint labels to send its hostnames
through that Gateway. Policy is enforced inside ztunnel itself, in the
ztunnel pod's network namespace, before the packet leaves the node.
Read it like this. The workload pod opens a plain TCP
connection to httpbin.org:443. The CNI redirect grabs that
packet and hands it to the local ztunnel. ztunnel checks two
AuthorizationPolicies in turn, one attached to the
ztunnel-egress Gateway and one attached to the matching
ServiceEntry. If both allow it, the packet leaves the cluster from
ztunnel's own network namespace. A separate NetworkPolicy on
the workload namespace prevents the pod from reaching any external IP
directly, so there is no way around the gateway.
Use cases that fit this shape
L4 ztunnel-native egress trades HTTP-level controls for simplicity and zero extra pods. That is the right trade in a lot of places.
🏦 PCI scope reduction
Only the payments namespace gets to talk to the card
processor's API. Everyone else is denied. The egress policy is itself an
auditable artefact: one Gateway, one ServiceEntry, two AuthorizationPolicies.
🧠 LLM and SaaS API allow-listing
The platform team wants to allow a known list of model endpoints and block everything else. Each model gets a ServiceEntry, each consuming namespace gets a narrow AuthorizationPolicy, and shadow IT through some random LLM SaaS provider fails closed.
🪪 Per-identity outbound
A single namespace runs more than one workload, and only one of them is allowed to call the partner API. The policy targets the SPIFFE principal of that service account, not the namespace, so the other workloads in the same namespace can't reach the host even if they try.
🚫 Default-deny outbound
The Helm chart's egressGateway.defaultDeny=true flag plus a
cluster-wide NetworkPolicy turn the cluster into one where outbound is
explicit by default. Adding a new external destination becomes a code
review, not a Slack message.
Setup, step by step
This is the path from a stock ambient install to a working ztunnel-native egress policy. Each step is independently reversible: drop the chart, drop the Gateway, and you are back where you started.
Enable egress in ztunnel
The egress code path is gated by a Helm value on the ztunnel chart.
Flip it on and ztunnel will register itself with the new
solo-ztunnel-egress GatewayClass that istiod creates. This
is a Helm-only install today; Gloo Operator support is not in 1.30.
helm upgrade ztunnel \
oci://us-docker.pkg.dev/soloio-img/istio-helm/ztunnel \
--version 1.30.0-beta.2-solo \
--namespace istio-system \
--reuse-values \
--set enableEgressGateway=true
Install the ztunnel-egress chart with default-deny
The ztunnel-egress chart provisions the Gateway resource,
binds it to the existing ztunnel DaemonSet, and ships a default-deny
AuthorizationPolicy. Skip this and the Gateway accepts every
ambient-captured source, which is the wrong starting point. The default
for egressGateway.defaultDeny is false, so
you have to set it explicitly.
helm upgrade -i ztunnel-egress \
oci://us-docker.pkg.dev/soloio-img/istio-helm/ztunnel-egress \
--version 1.30.0-beta.2-solo \
--namespace istio-system \
--set egressGateway.defaultDeny=true
The mechanism is subtle: the default-deny policy is an
ALLOW AuthorizationPolicy with an empty rules: []
list. In Istio authz, an ALLOW policy that matches nothing denies
everything through that target unless a more specific ALLOW policy
permits it. That is what makes step 4 the gate that has to be opened
for any source to use the egress at all.
If you prefer declarative resources, the next two YAML blocks are what the chart applies for you.
🛣️ Gateway · ztunnel-egress gateway.networking.k8s.io/v1
The Gateway uses class solo-ztunnel-egress with a single
HBONE listener on port 15008. istiod creates the
GatewayClass automatically when ztunnel is started with the egress flag
set, so you do not have to declare it.
Gateway · ztunnel-egressapply in istio-system
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: ztunnel-egress
namespace: istio-system
labels:
istio.io/waypoint-for: service
spec:
gatewayClassName: solo-ztunnel-egress
listeners:
- name: mesh
port: 15008
protocol: HBONE
allowedRoutes:
namespaces:
from: All
AuthorizationPolicy · default-deny on the gatewayapply in istio-system
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: default-deny-ztunnel-egress
namespace: istio-system
spec:
targetRefs:
- kind: Gateway
group: gateway.networking.k8s.io
name: ztunnel-egress
action: ALLOW
rules: []
Declare an external host with a ServiceEntry
The ServiceEntry tells the mesh that httpbin.org exists,
and the istio.io/use-waypoint labels send the traffic
through the ztunnel-egress Gateway in
istio-system. Without this binding the ServiceEntry exists
but ztunnel won't pull it through the egress policy.
🛰️ ServiceEntry · bound to the egress Gateway networking.istio.io/v1
The solo.io/sidecar-skip-waypoint annotation matters when
the same cluster runs sidecar workloads. Sidecar-injected workloads
architecturally cannot use ztunnel-native egress at all (the feature
relies on ztunnel's in-process handoff and there is no listener for
sidecars to route to). The annotation tells sidecars to skip the
use-waypoint binding and keep using registry-based routing instead.
Ambient workloads in the same cluster still honour the binding and
go through the gateway.
ServiceEntry · httpbin.orgapply in the consuming namespace
apiVersion: networking.istio.io/v1
kind: ServiceEntry
metadata:
name: httpbin-org
namespace: bookinfo
labels:
istio.io/use-waypoint: ztunnel-egress
istio.io/use-waypoint-namespace: istio-system
annotations:
solo.io/sidecar-skip-waypoint: "true"
spec:
hosts:
- httpbin.org
ports:
- number: 443
name: https
protocol: TLS
resolution: DNS
location: MESH_EXTERNAL
Quick check that the binding actually took. The ServiceEntry should
report a WaypointBound status condition with reason
WaypointAccepted:
kubectl get serviceentry httpbin-org -n bookinfo -o yaml
# status.conditions[]:
# type: istio.io/WaypointBound
# reason: WaypointAccepted
# status: "True"
Allow the source at the Gateway layer
The default-deny in step 02 means no source can use the gateway. Open it for the source namespaces or service accounts that need outbound. This layer enforces who can use the egress gateway at all.
🔐 AuthorizationPolicy · Gateway layer security.istio.io/v1
Target the Gateway via targetRefs. Match by namespace, by
SPIFFE principal, or by destination port. DENY takes precedence over
ALLOW, so you can layer an allow-list with a tighter deny. One gotcha
worth knowing: unlike istio-waypoint, the
solo-ztunnel-egress GatewayClass cannot be targeted
directly. Policies have to point at the specific Gateway resource by
name.
AuthorizationPolicy · allow a namespaceapply in istio-system
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: allow-bookinfo-egress
namespace: istio-system
spec:
targetRefs:
- kind: Gateway
group: gateway.networking.k8s.io
name: ztunnel-egress
action: ALLOW
rules:
- from:
- source:
namespaces: ["bookinfo"]
AuthorizationPolicy · allow a namespace on :443 onlyapply in istio-system
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: allow-bookinfo-https-only
namespace: istio-system
spec:
targetRefs:
- kind: Gateway
group: gateway.networking.k8s.io
name: ztunnel-egress
action: ALLOW
rules:
- from:
- source:
namespaces: ["bookinfo"]
to:
- operation:
ports: ["443"]
AuthorizationPolicy · explicit deny overrideapply in istio-system
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: deny-default-egress
namespace: istio-system
spec:
targetRefs:
- kind: Gateway
group: gateway.networking.k8s.io
name: ztunnel-egress
action: DENY
rules:
- from:
- source:
namespaces: ["default"]
Allow the source at the ServiceEntry layer
The second layer attaches to the ServiceEntry and decides who can reach this specific destination. Both layers must allow the source. A common pattern is broad allows at the Gateway and tight per-destination allows at the ServiceEntry layer, so adding a new vendor host doesn't require touching the gateway policy.
🔐 AuthorizationPolicy · ServiceEntry layer security.istio.io/v1
Target the ServiceEntry by name. principals matches the
full SPIFFE identity, which is the right tool when more than one
workload lives in the same namespace and only one of them should be
able to call out.
AuthorizationPolicy · whole namespace can reach httpbinapply in the ServiceEntry's namespace
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: allow-bookinfo-to-httpbin
namespace: bookinfo
spec:
targetRefs:
- kind: ServiceEntry
group: networking.istio.io
name: httpbin-org
action: ALLOW
rules:
- from:
- source:
namespaces: ["bookinfo"]
AuthorizationPolicy · only one SPIFFE principalapply in the ServiceEntry's namespace
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: allow-productpage-to-httpbin
namespace: bookinfo
spec:
targetRefs:
- kind: ServiceEntry
group: networking.istio.io
name: httpbin-org
action: ALLOW
rules:
- from:
- source:
principals:
- "cluster.local/ns/bookinfo/sa/bookinfo-productpage"
Stop workloads bypassing the mesh with a NetworkPolicy
Mesh authz is a routing decision. A workload that opens a raw socket to
an external IP simply skips the proxy and the policy with it. The fix
is a stock Kubernetes NetworkPolicy that allows DNS and
in-cluster traffic but refuses any other egress from workload pods. The
podSelector excludes pods labelled as a gateway, so ztunnel itself is
untouched and still functions.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: workload-egress
namespace: bookinfo
spec:
podSelector:
matchExpressions:
- key: gateway.networking.k8s.io/gateway-name
operator: DoesNotExist
policyTypes:
- Egress
egress:
- ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
- to:
- podSelector: {}
Forcing every pod through the egress
Step 6 above is the fence that actually keeps workloads on the egress
path. On its own it's one of four controls you want in place, though.
ztunnel and AuthorizationPolicy decide who is allowed
to use the egress. They do not decide who is forced to use
it. That distinction trips most teams up.
Five layers, all wanted:
Capture every workload pod with ambient mode
Label every workload namespace
istio.io/dataplane-mode=ambient. That is what triggers
ztunnel's CNI redirect to grab outbound traffic from the pod's
network namespace. Without the label, ztunnel never sees the pod
and the policy story never starts.
Enforce the label at namespace creation with a Kyverno policy or a
ValidatingAdmissionPolicy. If a developer can spin up a
namespace without the label, they can spin up a workload that skips
the mesh entirely.
Fence workload egress with a NetworkPolicy
This is the YAML in step 6. It allows DNS and in-cluster traffic and refuses everything else from workload pods. ztunnel itself is exempt because it runs in its own network namespace as a DaemonSet, not as a pod inside the workload namespace, so it can still egress freely while workloads cannot.
Without this layer, a workload can open a raw socket straight to an
external IP and ztunnel's authz never fires. Mesh policy is a routing
decision. NetworkPolicy is enforcement at L3/L4.
Catch unregistered destinations with ztunnel egressPolicies
The Gateway AuthorizationPolicy in step 4 only fires for traffic that ztunnel can match to a known destination in the service registry. If a workload dials an IP literal with no matching ServiceEntry, ztunnel cannot route it through the egress gateway and the AuthZ layer is never evaluated. The NetworkPolicy in layer 2 stops the packet eventually, but you also want a control inside ztunnel itself.
Set egressPolicies on the ztunnel Helm chart to deny any
traffic ztunnel cannot match to a known destination. Two controls work
in concert: egress policies handle unknown destinations, the Gateway
AuthorizationPolicy handles known ones.
# values for the ztunnel chart
egressPolicies:
- matchCidrs:
- 0.0.0.0/0
- ::/0
policy: Deny
Block hostNetwork: true pods at admission
A pod running with hostNetwork: true sits in the node's
network namespace, not its own. CNI redirect does not apply. The
NetworkPolicy in layer 2 does not apply either, because the policy
selects on the pod's network identity and the pod is now using the
node's. The workload has unimpeded access to whatever the node can
reach.
Pod Security Admission at restricted or
baseline level rejects hostNetwork pods. Apply it on
every workload namespace by label, alongside the ambient label:
apiVersion: v1
kind: Namespace
metadata:
name: bookinfo
labels:
istio.io/dataplane-mode: ambient
pod-security.kubernetes.io/enforce: restricted
Drop pod-CIDR egress at the perimeter
Belt and braces. At the VPC or security-group level, drop outbound from the pod CIDR. Allow outbound only from the node IPs that ztunnel uses for egress SNAT. Even if every in-cluster control fails (admission policy disabled, NetworkPolicy deleted, namespace label dropped) the packet dies at the cloud network and never reaches the internet.
This is the layer auditors look for, because it does not depend on anything inside Kubernetes being correctly configured.
Mixed-mode clusters: sidecar-skip-waypoint
A cluster that runs sidecar workloads alongside ambient ones needs the
solo.io/sidecar-skip-waypoint: "true" annotation on any
ServiceEntry bound to the egress Gateway. Sidecar-injected workloads
cannot use ztunnel-native egress at all (the feature depends on
ztunnel's in-process handoff and there is no listener for sidecars to
route to), but they do observe the istio.io/use-waypoint
label on the ServiceEntry. Without the annotation, the sidecar tries to
honour a binding it has no path to and outbound traffic to that host
fails. The annotation tells the sidecar to ignore the binding and stay
on registry-based routing. Ambient workloads in the same cluster honour
the binding and continue going through ztunnel-egress.
Resources at a glance
| Resource | Namespace | Role | Key fields |
|---|---|---|---|
Gateway ztunnel-egress |
istio-system |
Binds the existing ztunnel DaemonSet as the egress gateway. | gatewayClassName: solo-ztunnel-egress, listener on :15008 protocol HBONE. |
AuthorizationPolicy · gateway layer |
istio-system |
Decides which sources may use the egress gateway at all. | targetRefs to the Gateway, ALLOW with empty rules = default-deny. |
ServiceEntry |
Consuming namespace | Declares the external host and binds it to the egress Gateway. | istio.io/use-waypoint labels, solo.io/sidecar-skip-waypoint annotation in mixed mode. |
AuthorizationPolicy · ServiceEntry layer |
Same as the ServiceEntry | Decides which sources may reach this specific external host. | targetRefs to the ServiceEntry, match on namespaces or principals. |
NetworkPolicy |
Workload namespace | Keeps workload pods off the external network entirely. | podSelector excludes gateway pods, egress allows DNS plus in-cluster only. |
Where to go from here
The CRDs shown here are documented on the Istio Ambient CRDs visual map, and the lifecycle around ztunnel itself sits inside the Gloo Operator across N clusters page. For the wider context on how ambient enforces L4 versus L7 policy, the Solo docs page for L4 ztunnel-native egress is the canonical source; this article is the why and the shape, with the docs as the authoritative YAML reference.
Reminder that the feature is alpha in 1.30. Expect the Helm value names, the GatewayClass name and the annotation key to be stable from here on, but field-level details can still move before GA. Run it in non-prod first, lock down the workload namespaces with NetworkPolicy from day one, and you'll get most of the value with very little of the risk.