Identity first
Every workload in the mesh gets a certificate from istiod's CA whose identity is a SPIFFE ID built from its namespace and ServiceAccount:
spiffe://cluster.local/ns/shop/sa/web
trust domain ns service account
The certificate lives about 24 hours and is rotated by pilot-agent long before that - nobody copies or renews anything. The identity is the ServiceAccount, so give each workload its own ServiceAccount: with everything on default, every pod in the namespace is the same "person".
What you need to know already: mutual TLS (9.19), ServiceAccounts (17.18), the previous two lessons, NetworkPolicy as the L3/L4 comparison (16.29).
Auto mTLS and PeerAuthentication
Out of the box Istio uses auto mTLS: a client sidecar talking to a pod that has a sidecar uses mTLS automatically; to a pod without one it sends plaintext. The server side decides what it accepts, with PeerAuthentication:
PERMISSIVE accept mTLS and plaintext (the default - safe for migrations)
STRICT accept mTLS only: plaintext connections are reset
DISABLE plaintext only
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: default
namespace: istio-system # the root namespace = the whole mesh
spec:
mtls:
mode: STRICT
Precedence: a policy with a workload selector beats the namespace-wide one (no selector, in that namespace), which beats the mesh-wide one (no selector, in istio-system). portLevelMtls (on a workload policy) overrides one port - keyed by the container port, not the Service port.
What a non-mesh client sees against STRICT is not an HTTP error:
$ curl -sS http://ledger.bank:8000/get
curl: (56) Recv failure: Connection reset by peer
and the server's sidecar logs a line with no request in it:
[2026-10-07T10:02:11.120Z] "- - -" 0 NR filter_chain_not_found - "-" 0 0 0 - "-" "-" "-" "-" "-" - - 10.244.2.31:8080 10.244.1.17:44912 - -
filter_chain_not_found: the inbound listener only has a filter chain for TLS, the client spoke plaintext. That line is the signature of "STRICT broke a client that is not in the mesh".
Migrating to STRICT safely: leave PERMISSIVE, check that every caller already uses mTLS (the metric label connection_security_policy="mutual_tls" on istio_requests_total, or XFCC headers), inject or exempt the stragglers, then switch.
The client side: DestinationRule TLS
A DestinationRule's trafficPolicy.tls.mode overrides auto mTLS for traffic to a host: ISTIO_MUTUAL (force mesh mTLS), DISABLE (force plaintext), SIMPLE/MUTUAL (TLS to something outside the mesh). The classic mistake: DISABLE towards a STRICT server - every call fails with 503 and flag UC ("upstream connect error ... reset reason: connection termination").
Seeing it
The server sidecar adds an X-Forwarded-Client-Cert (XFCC) header to requests that arrived over mTLS - httpbin's /headers shows it:
"X-Forwarded-Client-Cert": ["By=spiffe://cluster.local/ns/demo/sa/httpbin;Hash=0dcf...;Subject=\"\";URI=spiffe://cluster.local/ns/demo/sa/curl"]
By = the server, URI = the client. istioctl x describe pod prints the effective mode, and istioctl proxy-config secret POD the proxy's certificate (ACTIVE, its serial, not before / not after).
AuthorizationPolicy
mTLS answers "who is calling". AuthorizationPolicy answers "may they":
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: orders-allow
namespace: shop
spec:
selector:
matchLabels:
app: orders # applies to the orders pods' sidecars
action: ALLOW
rules:
- from:
- source:
principals: ["cluster.local/ns/shop/sa/frontend"] # no spiffe:// prefix
to:
- operation:
methods: ["GET", "POST"]
paths: ["/api/*"]
The rules every on-call engineer must know:
- Evaluation order: CUSTOM, then DENY, then ALLOW. A matching DENY always wins.
- If a workload has no ALLOW policy, everything not denied is allowed. As soon as it has one, only requests matching some ALLOW rule pass.
- An ALLOW policy with an empty spec (
spec: {}) matches nothing: deny all. The usual baseline, per namespace, before opening what is needed. principalsandnamespacescome from the mTLS certificate: they only match mTLS traffic. Plaintext callers have no principal - so a rule with principals never matches them.- Denied requests get 403 with the body
RBAC: access deniedfrom the server sidecar; the access log showsrbac_access_denied_matched_policy[...]. A 403 the app never saw.
istioctl x authz check POD the policies that apply to a pod and their rule counts
AuthorizationPolicy is L7 (methods, paths, headers) and identity-based; NetworkPolicy is L3/L4 and address-based. Keep both: NetworkPolicy still protects against pods without a sidecar.
In an interview: "PeerAuthentication says what a workload accepts - PERMISSIVE or STRICT mTLS; AuthorizationPolicy says who may call what, using the SPIFFE identity from the certificate. A plaintext client against STRICT gets a connection reset; an mTLS client a policy denies gets 403 RBAC: access denied."
What you can now do:
- read a workload's SPIFFE identity and prove a call used mTLS
- set STRICT at mesh, namespace or workload level and predict who breaks
- write an allow-list AuthorizationPolicy and read a 403 RBAC denial