MastertheMesh
Solo Enterprise for Istio · 1.30 · Alpha
Alpha

L4 ztunnel-native egress: identity-based egress without a new proxy

TO
Tom O'Rourke
EMEA Field CTO · Solo.io

Solo Enterprise for Istio 1.30 ships an alpha that lets the existing ztunnel DaemonSet act as the egress gateway for ambient workloads. No new pods, identity-based L4 policy, and a clean story for pairing with NetworkPolicy so workloads can't sneak past it. This page is the why, the where it fits, and the YAML.

ztunnel egress AuthorizationPolicy NetworkPolicy SEFI 1.30 · Alpha

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.

CLUSTER · ambient · network namespace boundary EXTERNAL · httpbin.org : 443 node workload pod and local ztunnel share the node, not the network namespace Workload pod ns: bookinfo SA: bookinfo-productpage opens TCP to httpbin.org:443 no SDK, no sidecar NetworkPolicy: no external IPs from this pod ztunnel (DaemonSet) CNI redirect intercepts 1. resolves dest via ServiceEntry 2. evaluates Gateway AuthZ 3. evaluates ServiceEntry AuthZ exits from ztunnel pod's net ns ALLOW both layers matched source DENY default-deny or explicit DENY External host httpbin.org : 443 declared by a ServiceEntry labelled use-waypoint: ztunnel-egress plain TCP forward Two AuthorizationPolicy layers must both ALLOW. DENY overrides ALLOW. NetworkPolicy keeps workloads off the external network. The egress gateway is the existing ztunnel DaemonSet. No new Envoy pods are deployed.
pod ztunnel external host deny / NetworkPolicy fence allow

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.

STEP 01

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
STEP 02

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: []
STEP 03

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"
STEP 04

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"]
STEP 05

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"
STEP 06

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:

LAYER 01

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.

LAYER 02

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.

LAYER 03

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
LAYER 04

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
LAYER 05

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.

Don't skip this annotation in a mixed cluster. Without it, sidecar workloads observe the use-waypoint label and behave unpredictably depending on the sidecar's xDS state. The symptom looks like sidecar workloads losing access to the external host, intermittently.

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.