Egress & east-west control
Authorize outbound through an egress gateway.
A secure office spends its whole budget on the lobby. Badge readers, a guard, a visitor log, a camera pointed at the turnstile. Round the back there is a loading dock, and nobody watches what rolls out of it. Most zero-trust programs are built like that building: hard on the way in, decent between floors, wide open on the way out. Egress, meaning everything a workload is allowed to reach on the outbound side, is the loading dock. An attacker who already has a foothold does not need to beat the lobby a second time. They need one unwatched exit.
Three directions, and the one nobody guards
A workload sees traffic in three directions, and teams reliably guard two of them. Ingress is the outside world coming in, the front door. East-west is service calling service inside the cluster, the corridors between offices. Egress is your workload dialling out: a partner API (application programming interface, the door another company opens for your code), a package registry, or, once something has gone wrong, an attacker's C2 server (command and control, the channel malware uses to receive orders and hand back whatever it stole). A real breach uses all three. Land on one pod, move east-west until you find the data, push it out through egress. Guard the front door only and you have interrupted none of those moves.
Prove the inside doors latch
You built the east-west half in the identity-based authorization lesson: an AuthorizationPolicy keyed on the caller's SPIFFE principal (Secure Production Identity Framework For Everyone, the standard that gives every workload a name like spiffe://cluster.local/ns/prod/sa/payments) rather than on its IP address (Internet Protocol address, the number a pod happens to hold today and loses tomorrow), with an allow-nothing baseline so nothing reaches anything until a rule names it. Two things need checking before you cut a new exit. The first is that nobody can skip the proof entirely.
# A PeerAuthentication called "default" in the mesh root namespace (istio-system# unless you changed it) applies to every workload in the mesh.## Every manifest in this lesson uses the v1 generation of the Istio APIs,# networking.istio.io/v1 and security.istio.io/v1, which Istio has served since# 1.22. Older clusters carry identical fields under v1beta1 or v1alpha3; only# the apiVersion line differs.apiVersion: security.istio.io/v1kind: PeerAuthenticationmetadata:name: defaultnamespace: istio-systemspec:mtls:mode: STRICT # receiving sidecars refuse plaintext, full stop
With STRICT in force, the second check is that the inside doors actually latch. Test it the way an attacker would, from a workload you never put on the allowlist, and confirm the receiving sidecar refuses the call before a single byte reaches application code.
# Call the backend from a workload that is NOT on its allowlist.kubectl exec -n prod deploy/reporting -c reporting -- \curl -sS -o /dev/null -w '%{http_code}\n' http://backend.prod:8080/api/orders# Then ask the RECEIVING sidecar which decision closed the door. Access logging# has to be on for this (meshConfig.accessLogFile: /dev/stdout). If you get# nothing back, that is why, and the logging section below turns it on.kubectl logs -n prod deploy/backend -c istio-proxy --tail=1
403[2026-07-21T09:14:02.331Z] "GET /api/orders HTTP/1.1" 403 - rbac_access_denied_matched_policy[none] - "-" 0 19 0 - "-" "curl/8.11.1" "8b8c1a4e-6d21-9a07-bb14-2f0c3d5e7a91" "backend.prod:8080" "-" inbound|8080|| - 10.1.4.9:8080 10.1.7.22:44318 outbound_.8080_._.backend.prod.svc.cluster.local default
rbac_access_denied_matched_policy[none] is Envoy saying its authorization filter (RBAC, role-based access control) turned the request away because no ALLOW rule matched it. The 19 bytes it sent back are the string RBAC: access denied. Now be pedantic about what got you here, because every egress design gets burned by the gap. Ordinary TLS (Transport Layer Security, the encryption behind the padlock in a browser) is one-sided: the server shows identification, the client shows none. Mutual TLS makes both ends present a certificate, the way two people each show a badge at a door instead of only the visitor. What the receiving proxy learns from that is narrow, and worth saying out loud. Whoever opened this connection holds the private key for a certificate signed by something in the trust bundle (the set of public root certificates the mesh accepts signatures from), and that certificate says spiffe://cluster.local/ns/prod/sa/reporting. Strong claim about who. It says nothing about what they intend, and nothing about whether that pod was taken over an hour ago. Authorization is the separate step that turns a proven name into a narrow permission, and it is the part that bounds damage.
Istio ships with the exit propped open
Here is the setting that quietly undoes egress work. Every mesh has a doorman for outbound traffic, and Istio's ships waving everybody through. A sidecar decides what to do with a destination it does not recognise based on meshConfig.outboundTrafficPolicy.mode, and the shipped default is ALLOW_ANY: unknown hosts pass straight out. Under that value every allowlist you write is decoration, because nothing was ever denied to begin with. REGISTRY_ONLY flips the logic. Any host missing from the mesh's service registry gets routed into a blackhole cluster and the connection dies. Read yours before you assume it.
# What does this mesh do with a host it has never heard of?kubectl -n istio-system get configmap istio -o jsonpath='{.data.mesh}' \| grep -A1 outboundTrafficPolicy# No output means the field was never set, which means ALLOW_ANY.# The default profile installs no egress gateway. Confirm one exists and that it# carries the label your Gateway selector will look for.kubectl -n istio-system get deploy -l istio=egressgateway
outboundTrafficPolicy:mode: REGISTRY_ONLYNAME READY UP-TO-DATE AVAILABLE AGEistio-egressgateway 2/2 2 2 36d
Two repairs if that came back wrong. To change the mode, re-run your original install with the flag appended, because istioctl install does not remember the flags you passed last time: istioctl install <your-original-flags> --set meshConfig.outboundTrafficPolicy.mode=REGISTRY_ONLY. If the deployment is missing, add it with --set components.egressGateways[0].name=istio-egressgateway --set components.egressGateways[0].enabled=true, or install the istio/gateway Helm chart into istio-system under the release name istio-egressgateway. That release name is not cosmetic. The chart derives the pod label from it by stripping the istio- prefix, which is how you end up with istio: egressgateway. Your Gateway resource selects pods by that label, and a selector matching nothing gives you a Gateway that looks applied and routes nothing at all.
One authorized way out
An egress gateway is a shipping desk. Nothing leaves the building in a stranger's backpack. It goes to one counter, a clerk checks your badge against what you are carrying, writes both in the ledger, and hands the parcel to the courier. Five objects build that counter for one partner API, and their shape is what makes identity work later. The ServiceEntry tells the mesh the host exists. The Gateway opens a port on the egress pods and terminates Istio mutual TLS there, which is the line that creates identity. The first DestinationRule tells sidecars to speak that mutual TLS on the way to the gateway and to set the right SNI (Server Name Indication, the destination hostname a TLS client announces in the clear before the encrypted handshake starts, like the address written on the outside of a sealed envelope). The VirtualService stitches the two hops together. The second DestinationRule makes the gateway, rather than your application, start real TLS to the partner.
# 1) The mesh has to know this host exists before it can allow it.apiVersion: networking.istio.io/v1kind: ServiceEntrymetadata:name: partner-apinamespace: prodspec:hosts: ["api.partner.com"]location: MESH_EXTERNAL # the default; say it out loud anywayresolution: DNS # the proxy does the hostname lookup itself# exportTo narrows who can see this host. The egress gateway lives in# istio-system and needs it too, or the gateway has no route to the partner.exportTo: [".", "istio-system"]ports:- {number: 80, name: http, protocol: HTTP} # what your pods dial- {number: 443, name: https, protocol: HTTPS} # what the gateway dials---# 2) The counter itself. ISTIO_MUTUAL is the line that creates identity: the# gateway terminates the caller's mTLS, so it holds a peer certificate.# Port 80 here is the Service port. The pod listens on 8080; see below.apiVersion: networking.istio.io/v1kind: Gatewaymetadata:name: egress-partnernamespace: istio-systemspec:selector:istio: egressgatewayservers:- port: {number: 80, name: https-partner, protocol: HTTPS}hosts: ["api.partner.com"]tls:mode: ISTIO_MUTUAL---# 3) The hop from your pod to the gateway needs two things spelled out: use# Istio mutual TLS, and set the SNI to the partner hostname so the gateway# picks the server block above. Leave this out and the connection arrives# with the mesh's default SNI and matches no filter chain.apiVersion: networking.istio.io/v1kind: DestinationRulemetadata:name: egressgateway-for-partnernamespace: prodspec:host: istio-egressgateway.istio-system.svc.cluster.localsubsets:- name: partnertrafficPolicy:portLevelSettings:- port: {number: 80}tls:mode: ISTIO_MUTUALsni: api.partner.com---# 4) Two hops in one object: pod -> gateway, then gateway -> partner.apiVersion: networking.istio.io/v1kind: VirtualServicemetadata:name: partner-egressnamespace: prodspec:hosts: ["api.partner.com"]# The Gateway lives in istio-system, so reference it namespace-qualified. A# bare name resolves to prod/egress-partner and binds to nothing, silently.gateways: [mesh, istio-system/egress-partner]http:- match: [{gateways: [mesh], port: 80}]route:- destination:host: istio-egressgateway.istio-system.svc.cluster.localsubset: partnerport: {number: 80}- match: [{gateways: [istio-system/egress-partner], port: 80}]route:- destination: {host: api.partner.com, port: {number: 443}}---# 5) The gateway speaks TLS to the partner on your behalf.apiVersion: networking.istio.io/v1kind: DestinationRulemetadata:name: originate-tls-for-partnernamespace: istio-systemspec:host: api.partner.comtrafficPolicy:portLevelSettings:- port: {number: 443}tls:mode: SIMPLE # MUTUAL + credentialName: <secret> if the partner# wants a client certificate from you
Notice what changed against the naive design. Your application dials plain http://api.partner.com on port 80. The sidecar wraps that in Istio mutual TLS and carries it to the gateway, the gateway unwraps it, and the gateway opens a fresh TLS session to the partner on 443. State the trade-off plainly, because it is a real one: you gave up end-to-end TLS from application to partner, and your request now exists in the clear inside the gateway process. What you bought is the only thing that makes identity-based egress possible. Because the gateway completed a mutual handshake it holds the caller's certificate, and because it sees HTTP (HyperText Transfer Protocol, the plain request-and-response format the web runs on) it can read the host, the method and the path.
The policy that actually decides
Now the guest list. Two policies: a baseline that closes the counter to everybody, and one narrow grant. An AuthorizationPolicy with a selector and no rules is an ALLOW policy that can never match anything, and once any ALLOW policy applies to a workload, everything it does not match is denied. That is how you write default-deny in Istio. A rule that lets nobody in, followed by exactly the exceptions you meant.
# A) Baseline. ALLOW is the default action, and with zero rules it matches# nothing, so anything not explicitly granted below is denied at the gateway.apiVersion: security.istio.io/v1kind: AuthorizationPolicymetadata:name: egress-deny-allnamespace: istio-systemspec:selector:matchLabels:istio: egressgateway---# B) One grant. principals is the SPIFFE ID with the scheme stripped, read off# the peer certificate the ISTIO_MUTUAL listener just verified.apiVersion: security.istio.io/v1kind: AuthorizationPolicymetadata:name: egress-partner-allownamespace: istio-systemspec:selector:matchLabels:istio: egressgatewayaction: ALLOWrules:- from:- source:principals: ["cluster.local/ns/prod/sa/payments"]to:- operation:hosts: ["api.partner.com"] # matches the :authority headermethods: ["GET", "POST"]paths: ["/v1/*"]
hosts, methods and paths are available as options only because the gateway terminated TLS and is reading HTTP. principals is available only because that termination was mutual. Take either property away and the rule stops meaning what you think it means, which is the failure mode below.
tls: {mode: PASSTHROUGH} on the egress Gateway so the application's own TLS runs end to end. Do that and the gateway terminates nothing, so no peer certificate ever reaches the authorization filter. Istio's reference is explicit that principals and namespaces are derived from the peer certificate and require mutual TLS, so an ALLOW rule built on them matches nothing and denies every caller, including the one you meant to allow. The HTTP fields fail differently and equally badly: on a TCP or TLS listener Istio ignores an ALLOW rule that uses an HTTP-only field such as hosts, which leaves you with no valid ALLOW at all. A DENY policy in the same spot goes the other way, dropping the HTTP-only field and denying everything on the port. On a passthrough listener you can still pin the destination by SNI, but the only caller attribute left is an IP address, which is the exact thing workload identity exists to stop you trusting. Pick deliberately: end-to-end TLS with destination-only control, or terminate at the gateway and get identity plus L7 (layer 7, the application layer, meaning host, method and path).Prove the path, the identity and the denial
Configuration that nobody has tested is a guess with good indentation. Apply it, ask the gateway what it believes, then drive the allowed path and the blocked ones for real.
kubectl apply -f egress-partner.yaml -f egress-authz.yamlistioctl analyze -n prod# Does the gateway actually have a listener for this host?istioctl proxy-config listeners deploy/istio-egressgateway -n istio-system --port 8080
serviceentry.networking.istio.io/partner-api createdgateway.networking.istio.io/egress-partner createddestinationrule.networking.istio.io/egressgateway-for-partner createdvirtualservice.networking.istio.io/partner-egress createddestinationrule.networking.istio.io/originate-tls-for-partner createdauthorizationpolicy.security.istio.io/egress-deny-all createdauthorizationpolicy.security.istio.io/egress-partner-allow created✔ No validation issues found when analyzing namespace: prod.ADDRESS PORT MATCH DESTINATION0.0.0.0 8080 SNI: api.partner.com Route: https.80.https-partner.egress-partner.istio-system
The Gateway says port 80 and the listener answers on 8080. Gateway pods run unprivileged and cannot bind low ports, so the Service maps port 80 to target port 8080 and Istio builds the listener on the port the pod actually holds. That mismatch is a popular way to write a policy that never fires, and it is why the rule above matches on hosts and paths rather than on ports. Read the real port off this output before you match on one. Now the three tests that matter: the identity you granted, an identity you did not, and a destination nobody registered.
# 1) The identity on the allowlist:kubectl exec -n prod deploy/payments -c payments -- \curl -sS -o /dev/null -w '%{http_code}\n' http://api.partner.com/v1/ping# 2) A different identity in the same namespace, same request:kubectl exec -n prod deploy/reporting -c reporting -- \curl -sS http://api.partner.com/v1/ping# 3) A host nobody registered, with REGISTRY_ONLY in force:kubectl exec -n prod deploy/payments -c payments -- \curl -sI https://files.attacker.example | grep "HTTP/"
200RBAC: access deniedcommand terminated with exit code 35
Three different mechanisms produced those three results, and separating them is the point of the exercise. The 200 went out through the gateway. The RBAC: access denied came from the authorization filter on the gateway, after it verified a certificate it recognised and then found no rule granting that name. Exit code 35 is curl reporting that the TLS handshake never got started, because REGISTRY_ONLY sent an unregistered host into a blackhole and Envoy closed the connection first. Now prove the wire really carries a name, and not an address you have decided to trust.
istioctl proxy-config secret deploy/payments -n prod -o json \| jq -r '.dynamicActiveSecrets[] | select(.name=="default")| .secret.tlsCertificate.certificateChain.inlineBytes' \| base64 -d | openssl x509 -noout -text | grep -A1 "Subject Alternative Name"
X509v3 Subject Alternative Name: criticalURI:spiffe://cluster.local/ns/prod/sa/payments
That certificate is the workload's SVID (SPIFFE Verifiable Identity Document, a short-lived certificate whose only job is to carry this name, like a contractor badge that stops working at the end of the shift), and the name sits in its SAN (Subject Alternative Name, the certificate field that holds identifiers other than a plain hostname). Strip the spiffe:// scheme and it is character-for-character the string in principals. If that field is empty or spells something else, your identity rules are matching nothing and you have a default-deny nobody planned.
Where does the name come from in the first place? With Istio's built-in certificate authority, istiod mints it from the pod's service account. If your mesh takes identities from SPIRE instead (the SPIFFE Runtime Environment, the reference implementation that attests workloads and hands out SVIDs over the Workload API), the same string comes from a registration entry, and the selectors are what tie it to a real pod rather than to anything the pod says about itself.
# Mint the identity the AuthorizationPolicy will name. The selectors are facts# the agent checks with the kubelet before it hands an SVID to anything.spire-server entry create \-spiffeID spiffe://cluster.local/ns/prod/sa/payments \-parentID spiffe://cluster.local/spire/agent/k8s_psat/prod-cluster/a1f0c3d2 \-selector k8s:ns:prod \-selector k8s:sa:payments
Entry ID : 4c0d9b1e-7a3f-4f2b-9c65-0f2a7d3e1b88SPIFFE ID : spiffe://cluster.local/ns/prod/sa/paymentsParent ID : spiffe://cluster.local/spire/agent/k8s_psat/prod-cluster/a1f0c3d2Revision : 0X509-SVID TTL : defaultJWT-SVID TTL : defaultSelector : k8s:ns:prodSelector : k8s:sa:payments
The trust domain in that SPIFFE ID (cluster.local here) has to match the one your mesh issues under. Get it wrong and the gateway sees a certificate from a root it does not carry, the handshake fails, and principals never gets the chance to match anything.
Log the caller, not the connection
The gateway is your outbound ledger, and the page it ships with is missing the one column an investigation needs. Istio's stock access log records the destination, the response code and the SNI, but never who called. Add %DOWNSTREAM_PEER_URI_SAN%, the URI taken from the peer certificate, and every outbound flow becomes attributable to a workload identity instead of a pod IP that was recycled twenty minutes later.
# Same three fields whether you set them with istioctl install --set,# an IstioOperator file, or Helm values.meshConfig:accessLogFile: /dev/stdoutaccessLogEncoding: JSONaccessLogFormat: |{"t":"%START_TIME%","caller":"%DOWNSTREAM_PEER_URI_SAN%","host":"%REQ(:AUTHORITY)%","path":"%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%","code":"%RESPONSE_CODE%","why":"%RESPONSE_CODE_DETAILS%","upstream":"%UPSTREAM_HOST%","sni":"%REQUESTED_SERVER_NAME%"}
kubectl logs -n istio-system deploy/istio-egressgateway --tail=2
{"t":"2026-07-21T09:41:12.884Z","caller":"spiffe://cluster.local/ns/prod/sa/payments","host":"api.partner.com","path":"/v1/ping","code":"200","why":"via_upstream","upstream":"198.51.100.24:443","sni":"api.partner.com"}{"t":"2026-07-21T09:41:29.117Z","caller":"spiffe://cluster.local/ns/prod/sa/reporting","host":"api.partner.com","path":"/v1/ping","code":"403","why":"rbac_access_denied_matched_policy[none]","upstream":"-","sni":"api.partner.com"}
Ship that stream to your SIEM (security information and event management platform, the system your detection team queries) and you have an outbound audit trail keyed on identity. Two detections fall out of it for almost no extra work. Any 403 at the egress gateway is a workload asking to leave through a door it was never granted. And a sudden change in bytes or request rate for one caller-and-host pair is what bulk exfiltration looks like on this graph.
A gateway routes, it does not contain
This is the failure teams meet during an incident rather than during design. A shipping desk is a sign telling people where to take parcels. It is not a wall. The egress gateway only ever handles traffic a cooperating sidecar chose to send it. A compromised pod can run a process that skips the sidecar, and anyone able to create pods can start one with injection turned off, and either of those opens a socket to the internet the gateway never sees. There is a quieter version of the same hole in the manifests above: the ServiceEntry lists port 443 so the gateway can dial the partner, and prod can see that entry too, so nothing in the mesh stops the payments pod opening its own TLS session straight to api.partner.com and walking past the counter. Istio's own documentation says the plain version of this. It cannot securely enforce that all egress traffic actually flows through the egress gateways, and you need firewalls or network policy to close the gap. What you need underneath is an L3 control (layer 3, the raw packet layer, which decides whether a pod may open a socket to an address at all). Mesh identity decides who may leave and where. The network layer is what guarantees there is no second door.
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata:name: default-deny-egressnamespace: prodspec:podSelector: {} # every pod in prod, including ones shipped next weekpolicyTypes: ["Egress"]egress:# Hostname lookups, or nothing resolves and you lose an afternoon to it.- to:- namespaceSelector:matchLabels: {kubernetes.io/metadata.name: kube-system}podSelector:matchLabels: {k8s-app: kube-dns}ports:- {protocol: UDP, port: 53}- {protocol: TCP, port: 53}# Sidecars must reach istiod for config and certificates, or the mesh dies.- to:- namespaceSelector:matchLabels: {kubernetes.io/metadata.name: istio-system}podSelector:matchLabels: {app: istiod}ports:- {protocol: TCP, port: 15012}# East-west stays reachable at L3; AuthorizationPolicy decides who calls whom.- to:- namespaceSelector:matchLabels: {kubernetes.io/metadata.name: prod}# The only way off the cluster.- to:- namespaceSelector:matchLabels: {kubernetes.io/metadata.name: istio-system}podSelector:matchLabels: {istio: egressgateway}ports:- {protocol: TCP, port: 8080}
Test it with the bypass an attacker would reach for: a pod with no sidecar at all. Without the policy, that pod talks to the internet exactly as though the mesh did not exist. With the policy applied, here is the same command.
# The pod label is the current way to opt out of injection. The annotation of# the same name still works but is deprecated; keep it out of new manifests.kubectl run bypass -n prod --rm -i --restart=Never \--labels sidecar.istio.io/inject=false \--image=curlimages/curl:8.11.1 -- \curl -sS -m 5 -o /dev/null https://files.attacker.example/
curl: (28) Connection timed out after 5001 millisecondspod prod/bypass terminated (Error)pod "bypass" deleted
That pod can still reach the gateway pods on 8080, and it gets nowhere: it holds no Istio certificate, so the ISTIO_MUTUAL listener drops it during the handshake, long before authorization runs. Two layers, two different reasons to say no. Both are needed, and the honest ceiling deserves saying out loud. Whoever owns the payments pod inherits the payments grant. Identity-based egress does not make exfiltration impossible. It narrows the channel to one host, two methods, one path prefix and a logged flow with a name attached, which is the difference between a quiet total loss and an alert with a suspect.
NetworkPolicy with policyTypes: [Egress] and no matching rule drops the packet, and sidecars are ordinary pods with ordinary network needs. Omit the lookup rule for DNS (Domain Name System, the service that turns a hostname into an address) and nothing resolves. Omit port 15012 to istiod and new pods never go ready, because their sidecars cannot fetch configuration or get a certificate signed. Pods already running carry on until their certificate expires, and Istio's default workload certificate lasts 24 hours, so you get up to a day of calm before the namespace comes apart. That delay is what makes the cause genuinely hard to spot. Expect to add rules for anything else prod legitimately dials, such as the Kubernetes API server. Roll it out in a staging namespace first, and remember that NetworkPolicy is enforced only if your CNI plugin (Container Network Interface, the component that actually wires pod networking) implements it. On a cluster whose CNI ignores policy, this object applies cleanly and does nothing whatsoever.tls: {mode: PASSTHROUGH} on the egress Gateway so the application's TLS stays end to end, and add an ALLOW AuthorizationPolicy with principals: ["cluster.local/ns/prod/sa/payments"]. What happens to the payments service?RBAC: access denied. A red team still exfiltrates to an arbitrary external IP from a pod in prod. What is the most likely explanation and the fix?One last thing belongs in the same pull request as the Gateway. A ServiceEntry is visible mesh-wide by default, so treat kubectl apply of one exactly like a firewall change: restrict who may create them with Kubernetes RBAC, and have Kyverno reject any whose hosts contain a wildcard, because *.partner.com hands back the exfiltration path you spent this lesson closing. Then put a review date on every host you allowed. A domain you trusted in March can be parked, sold or quietly hijacked by September, and nothing in this configuration will notice. The next lesson, Continuous verification, closes that gap by re-checking identity, posture and policy while a session is running instead of stamping it once on the way out the door.
Try this
Run curl -sS -o /dev/null -w '%{http_code}\n' http://backend.prod:8080/api/orders on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: pASSTHROUGH keeps your TLS and throws away the caller. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.