Istio Deep Dive — Why It Flows That Way
Where TLS terminates decides the shape of the gateway chain
In one line
The servers[].tls.mode of a Gateway decides the shape of the filter chain of the gateway Envoy listener. SIMPLE, MUTUAL, OPTIONAL_MUTUAL and ISTIO_MUTUAL attach a DownstreamTlsContext to the chain, so the gateway ends the TLS and does HTTP routing with the HCM. PASSTHROUGH and AUTO_PASSTHROUGH do not decrypt; they only peek at the SNI to choose a chain and then hand the bytes over through tcp_proxy. The surest evidence telling the two apart is whose certificate the client received.
Why this was needed
The ingress gateway is the first Envoy a client outside the mesh meets. Where to end TLS here is a trade-off between security and operations. If you end it at the gateway, you manage certificates in one place, you can look at the decrypted HTTP to route by path and header, and you can apply retries, timeouts and authorization. In exchange, the plain text is exposed once at the gateway. Services that need encryption all the way (a payment backend that verifies clients with its own certificate, a database that handles TLS directly) have to be handed over by the gateway without touching them.
Istio made this choice with one field of the Gateway. The problem is that the symptoms look alike. "The certificate name does not match", "the connection is cut right away" and "a 301 goes around forever" all make you fix the wrong place if you do not know where TLS ends. If you know what each mode creates in Envoy, one proxy-config listener shows the cause.
How it works
A Gateway opens only ports and certificates on the gateway Pod. Where traffic goes is decided by the VirtualService that points at that Gateway, so a Gateway alone leaves the routes empty (AUTO_PASSTHROUGH is the only exception). What is created on the gateway listener for each mode is this.
| Mode | Where TLS ends | Client certificate | What is created in Envoy |
|---|---|---|---|
| SIMPLE | Gateway | Not required | DownstreamTlsContext (server certificate) on the chain + HCM |
| MUTUAL | Gateway | Always required | The above plus require_client_certificate: true + validation_context |
| OPTIONAL_MUTUAL | Gateway | Verified if presented, passes if not | Only validation_context, with the requirement turned off |
| ISTIO_MUTUAL | Gateway | The mesh certificate issued by istiod | Receives the workload certificate and mesh root through SDS and verifies |
| PASSTHROUGH | Upstream | The gateway does not see it | tls_inspector + server_names match + tcp_proxy |
| AUTO_PASSTHROUGH | Upstream | The gateway does not see it | The SNI itself is a cluster name of the form outbound_.<포트>_.<subset>_.<호스트> (the placeholders are the port and the host), no VirtualService needed |
The certificate comes in not as a file but through SDS. credentialName: shop-cert points to a Secret in the same namespace as the gateway, and in the Envoy configuration it appears as the SDS name kubernetes://shop-cert. The CA for MUTUAL verification comes from ca.crt of the same Secret or from shop-cert-cacert.
The route configuration names follow a rule too. An HTTPS server gets https.<포트>.<포트이름>.<Gateway이름>.<네임스페이스> (the placeholders are the port, the port name, the Gateway name and the namespace; for example https.443.https.shop-gw.default), created separately for each Gateway, and a plain HTTP server gets one http.<포트> (the placeholder is the port), in which all the Gateway hosts on the same port are merged. httpsRedirect: true becomes one line, require_tls: ALL, in that plain-text server's virtual host, so before Envoy even looks at the route, it points to https with a 301. The host in Location is the request's Host header as it is, so if it has a port attached, that port remains too.
The reason the gateway can choose a destination in PASSTHROUGH is that the SNI is carried in plain text in the first message of TLS (the ClientHello). tls_inspector reads only that and matches it against the chain's server_names. If no chain matches, the connection is closed on the spot and listener.<주소>.no_filter_chain_match rises (the placeholder is the address). The SIMPLE chain Istio actually makes also has server_names attached — so that a different certificate can be chosen for each host on one port.
What it looks like in the field
It is PASSTHROUGH and changing the gateway certificate has no effect. What the client sees is the backend certificate from the start. You can tell right away by looking at the issuer of the certificate received with openssl s_client -servername.
Only clients that connect by IP, or old clients, get cut. If they do not send SNI, the PASSTHROUGH chain cannot be chosen. curl shows it only as a handshake failure (rc=35), so look at the gateway's no_filter_chain_match together.
After switching to MUTUAL, some clients say "connection reset". In TLS 1.3, the rejection arrives after the client thinks it has finished the handshake, so the error shapes vary. If ssl.fail_verify_no_cert on the Envoy side rises, they did not present a certificate.
The redirect points to a strange port. If a load balancer passes the Host header with the port attached, it remains in Location too. And if an LB health check comes into a plain-text server with httpsRedirect on, it gets a 301 and may be judged unhealthy.
Official documentation: Gateway · Secure Gateways · Envoy TLS
What you will do in the next lab
You write a Gateway, see istioctl require a certificate for SIMPLE, and then write the five modes out in Envoy's shape. You create a CA, gateway, client and backend certificates with openssl and start SIMPLE, MUTUAL and PASSTHROUGH listeners in turn, proving where TLS ended by the fingerprint of the certificate the client received. Finally you check what happens when the SNI does not match and the 301 of httpsRedirect.