CoursesZero-trust & workload identityEgress & east-west control

Egress & east-west control

Authorize outbound through an egress gateway.

Advanced30 min · lesson 14 of 15

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.

peer-auth.yaml
# 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/v1
kind: PeerAuthentication
metadata:
name: default
namespace: istio-system
spec:
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.

terminal
# 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
output
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.

terminal
# 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
output
outboundTrafficPolicy:
mode: REGISTRY_ONLY
NAME READY UP-TO-DATE AVAILABLE AGE
istio-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.

egress-partner.yaml
# 1) The mesh has to know this host exists before it can allow it.
apiVersion: networking.istio.io/v1
kind: ServiceEntry
metadata:
name: partner-api
namespace: prod
spec:
hosts: ["api.partner.com"]
location: MESH_EXTERNAL # the default; say it out loud anyway
resolution: 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/v1
kind: Gateway
metadata:
name: egress-partner
namespace: istio-system
spec:
selector:
istio: egressgateway
servers:
- 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/v1
kind: DestinationRule
metadata:
name: egressgateway-for-partner
namespace: prod
spec:
host: istio-egressgateway.istio-system.svc.cluster.local
subsets:
- name: partner
trafficPolicy:
portLevelSettings:
- port: {number: 80}
tls:
mode: ISTIO_MUTUAL
sni: api.partner.com
---
# 4) Two hops in one object: pod -> gateway, then gateway -> partner.
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: partner-egress
namespace: prod
spec:
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.local
subset: partner
port: {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/v1
kind: DestinationRule
metadata:
name: originate-tls-for-partner
namespace: istio-system
spec:
host: api.partner.com
trafficPolicy:
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.

egress-authz.yaml
# 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/v1
kind: AuthorizationPolicy
metadata:
name: egress-deny-all
namespace: istio-system
spec:
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/v1
kind: AuthorizationPolicy
metadata:
name: egress-partner-allow
namespace: istio-system
spec:
selector:
matchLabels:
istio: egressgateway
action: ALLOW
rules:
- from:
- source:
principals: ["cluster.local/ns/prod/sa/payments"]
to:
- operation:
hosts: ["api.partner.com"] # matches the :authority header
methods: ["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.

PASSTHROUGH keeps your TLS and throws away the caller
It is tempting to set 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.

terminal
kubectl apply -f egress-partner.yaml -f egress-authz.yaml
istioctl 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
output
serviceentry.networking.istio.io/partner-api created
gateway.networking.istio.io/egress-partner created
destinationrule.networking.istio.io/egressgateway-for-partner created
virtualservice.networking.istio.io/partner-egress created
destinationrule.networking.istio.io/originate-tls-for-partner created
authorizationpolicy.security.istio.io/egress-deny-all created
authorizationpolicy.security.istio.io/egress-partner-allow created
✔ No validation issues found when analyzing namespace: prod.
ADDRESS PORT MATCH DESTINATION
0.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.

terminal
# 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/"
output
200
RBAC: access denied
command 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.

terminal
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"
output
X509v3 Subject Alternative Name: critical
URI: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.

terminal
# 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
output
Entry ID : 4c0d9b1e-7a3f-4f2b-9c65-0f2a7d3e1b88
SPIFFE ID : spiffe://cluster.local/ns/prod/sa/payments
Parent ID : spiffe://cluster.local/spire/agent/k8s_psat/prod-cluster/a1f0c3d2
Revision : 0
X509-SVID TTL : default
JWT-SVID TTL : default
Selector : k8s:ns:prod
Selector : 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.

mesh-config.yaml
# Same three fields whether you set them with istioctl install --set,
# an IstioOperator file, or Helm values.
meshConfig:
accessLogFile: /dev/stdout
accessLogEncoding: JSON
accessLogFormat: |
{"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%"}
terminal
kubectl logs -n istio-system deploy/istio-egressgateway --tail=2
output
{"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.

netpol-egress.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-egress
namespace: prod
spec:
podSelector: {} # every pod in prod, including ones shipped next week
policyTypes: ["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.

terminal
# 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/
output
curl: (28) Connection timed out after 5001 milliseconds
pod 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.

Default-deny egress will take the namespace down if you forget two rules
A 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.
One outbound call, and every control it has to pass
1payments pod dials http://api.partner.com
plain HTTP on port 80, no TLS yet
2NetworkPolicy (L3)
default-deny egress: the only off-cluster route this pod has is the gateway pods on 8080
3Sidecar Envoy
ServiceEntry says the host exists, VirtualService sends it to the gateway, DestinationRule wraps it in Istio mTLS with SNI api.partner.com
4Egress gateway terminates ISTIO_MUTUAL
now a peer certificate exists: spiffe://cluster.local/ns/prod/sa/payments
5AuthorizationPolicy
ALLOW that principal, host api.partner.com, GET or POST, /v1/*; everything else gets 403 RBAC: access denied
6TLS origination to api.partner.com:443
DestinationRule SIMPLE, and the access log records the caller's SVID
Quick check
01Your mesh runs STRICT mTLS everywhere and every service carries a default-deny AuthorizationPolicy. A compromised pod still uploads the customer table to an attacker's server. Why did the east-west controls not stop it?
Incorrect — Wrong direction: STRICT mTLS did encrypt and authenticate those hops, and the pod exported data it was already allowed to read.
Correct — AuthorizationPolicy is enforced on the way into a selected workload, so nothing in that ruleset has an opinion about an external destination.
Incorrect — Principals describe the caller, not the destination, and the form of the destination is not what disabled the control here.
Incorrect — Forging an SVID means holding a private key the mesh certificate authority signed, which is a far larger compromise and not the ordinary explanation.
02You keep 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?
Correct — principals is derived from the peer certificate and requires mutual TLS, so on a passthrough listener the rule matches nothing and the ALLOW never fires.
Incorrect — SNI names the destination the client asked for, it is attacker-controlled text, and it is never a caller identity.
Incorrect — A proxy only holds a peer certificate if it completes the handshake itself, and a passthrough server forwards the encrypted stream without ever completing one.
Incorrect — Certificate rotation is handled by the proxy without losing identity, and it never produces this behaviour.
03REGISTRY_ONLY is confirmed, the gateway and policies are applied, and you have watched a non-allowlisted identity get 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?
Incorrect — It binds where the gateway pods run, and you already proved it enforces by watching a denied identity get a 403.
Incorrect — REGISTRY_ONLY blackholes unregistered destinations for TCP and TLS as well, which is why the unregistered HTTPS test failed with exit code 35.
Incorrect — Expired or missing identity causes a denial, never a fallback to allow.
Correct — mesh policy governs only what a cooperating sidecar forwards, so an L3 control is what makes the gateway the sole exit.

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.

Related