
Security
Istio Authorization Policy vs NetworkPolicy: Two Layers, Not One
Istio authorization policy decides on mTLS identity and L7 attributes; NetworkPolicy on IPs and ports. How each evaluates, where each fails open, what to show.
A common pattern in Kubernetes estates: a mesh is installed for traffic management and encryption, a few AuthorizationPolicy objects appear to unblock a specific call, and a year later the mesh is cited as the segmentation layer. A policy written to make connectivity work differs from one written to refuse everything else, and that gap is where an assessor's first question lands: show me the request that was denied.
Two policies, two layers: the verdict up front
Kubernetes NetworkPolicy controls traffic "at the IP address or port level (OSI layer 3 or 4)", and only if the cluster runs a network plugin that enforces it. Istio's AuthorizationPolicy evaluates the peer's mTLS identity and, in a proxy that terminates HTTP, attributes such as methods and paths. One asks "may this address reach that port?", the other "may this workload identity perform this operation?"
NIST SP 800-207A, the September 2023 zero-trust model for cloud-native applications, uses exactly this vocabulary: its keywords include network-tier policies, identity-tier policies, service mesh, sidecar proxy, and SPIFFE. Its framing tenet is to "remove the implicit trust in users, services, and devices based only on their network location". That argues for identity-tier policy, not for deleting the network tier.
- NetworkPolicy is the floor. It works without a mesh, it constrains workloads that never joined the mesh, and it is enforced by the CNI, outside the pod.
- AuthorizationPolicy is the identity tier. It can express "only the
frontendservice account mayPOSTto/api/*", which no IP rule can. - A segmentation claim should rest on both, evidenced separately. The network policy audit guide covers the network tier; this article covers the identity tier.
How AuthorizationPolicy evaluates a request
The AuthorizationPolicy reference fixes the order: when CUSTOM, DENY and ALLOW actions apply to the same workload, "the CUSTOM action is evaluated first, then the DENY action, and finally the ALLOW action." The ALLOW stage then resolves in three steps, and the first one is the one teams forget:
- If there are no ALLOW policies for the workload, allow the request.
- If any ALLOW policy matches, allow the request.
- Otherwise, deny the request.
Istio's security concepts page states the consequence without hedging: "For workloads without authorization policies applied, Istio allows all requests." Authorization is opt-in per workload, not switched on at install. And the deny-by-default behaviour "applies only if the workload has at least one authorization policy with the ALLOW action." A DENY alone does not create that posture; it removes one path from an otherwise open workload.
Scope comes from where the object lives and what it targets. A policy in a namespace applies to that namespace's workloads; a selector narrows it to matching pods; targetRefs attaches it to a Gateway, GatewayClass, or Service — which is how waypoint policy binds in ambient mode. A policy in the mesh root namespace (set by meshConfig.rootNamespace, typically istio-system) applies to all namespaces in the mesh.
Contrast that with NetworkPolicy, which has no deny action and no order. Kubernetes documents policies as additive: the allowed set for a pod is "the union of what the applicable policies allow. Thus, order of evaluation does not affect the policy result." An AuthorizationPolicy DENY can carve an exception out of an ALLOW; no NetworkPolicy can subtract from another.
Deny by default, actually: the allow-nothing baseline
Istio's security best-practices page says it plainly: "We recommend you define your Istio authorization policies following the default-deny pattern", in which the system denies everything and you define the conditions in which the requests are allowed. The mechanism is an AuthorizationPolicy with an empty spec, which the reference says "allows nothing and effectively denies all requests" to the workloads in its namespace. It works because the default action is ALLOW and the rule list is empty — an ALLOW that can never match. Writing action: DENY with no rules does the opposite: it matches nothing and denies nothing.
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: default
namespace: istio-system # your meshConfig.rootNamespace
spec:
mtls:
mode: STRICT
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: allow-nothing
namespace: shop
spec: {}Then write the conditions in which requests are allowed, using positive matches. Istio's guidance is ALLOW with positive matching and DENY with negative matching, because "the worst result in the case of policy mismatch is an unexpected 403 rejection instead of an authorization policy bypass." An ALLOW keyed on notPaths or notPrincipals fails the other way: a typo widens access instead of narrowing it.
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: orders-allow-frontend
namespace: shop
spec:
selector:
matchLabels:
app: orders
action: ALLOW
rules:
- from:
- source:
principals: ["cluster.local/ns/shop/sa/frontend"]
to:
- operation:
methods: ["GET", "POST"]
paths: ["/api/*"]
ports: ["8080"]Roll out with the istio.io/dry-run annotation first. Istio's dry-run task labels the feature Alpha; it evaluates the policy without enforcing it. Results appear in the proxy's debug log as shadow denied, matched policy ns[...]-policy[...]-rule[0], once rbac debug logging is enabled. Treat that log as rollout troubleshooting, not audit evidence: the task says shadow results should not be relied on as an API.
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: orders-allow-frontend
namespace: shop
annotations:
istio.io/dry-run: "true"
spec:
selector:
matchLabels:
app: orders
action: ALLOW
rules:
- from:
- source:
principals: ["cluster.local/ns/shop/sa/frontend"]
to:
- operation:
methods: ["GET", "POST"]
paths: ["/api/*"]
ports: ["8080"]In ambient mode with waypoints, the best-practices page adds a second baseline: an allow-nothing policy bound with targetRefs to the istio-waypoint GatewayClass, in the root namespace, used "in addition to the 'classic' default-deny policy." The classic one is still enforced by ztunnel against every workload and, in Istio's words, "still provides meaningful value." Neither replaces the other.
What an mTLS principal proves — and what it does not
The principals field matches "peer identities derived from the peer certificate", in the form <TRUST_DOMAIN>/ns/<NAMESPACE>/sa/<SERVICE_ACCOUNT>, and the reference adds that the field "requires mTLS enabled". On Kubernetes, Istio's service identity is the Kubernetes service account. So cluster.local/ns/shop/sa/frontend means the peer presented a mesh-CA certificate issued to a pod running as that account. It is SPIFFE-shaped; the SPIFFE/SPIRE workload identity guide covers issuing it, this article consuming it.
Mutual TLS is a precondition, not a detail. Istio lists the fields that need it: principals and notPrincipals, namespaces and notNamespaces, and the source.principal and source.namespace conditions. And by Istio's own best-practices page, "proxies are configured in permissive mode by default, meaning they will accept both mutual TLS and plaintext traffic" — which, the same page says, "weakens the security stance". The ambient L4 policy page states the same default for ambient mode: PERMISSIVE. A plaintext caller presents no certificate, so those fields are compared to an empty identity; Istio's bulletin ISTIO-SECURITY-2021-004 warns they "will never match when the traffic is plain text (non mTLS) and the request might be allowed unexpectedly." Installers may change that default; check your own PeerAuthentication objects.
The PeerAuthentication reference defines the modes precisely: STRICT means the connection "is an mTLS tunnel (TLS with client cert must be presented)"; UNSET inherits from a parent, "otherwise treated as PERMISSIVE". Istio's mTLS migration task documents the safe path: use the Grafana dashboard to see which workloads still send plaintext to PERMISSIVE workloads, and finish that before applying STRICT. Mesh-CA certificate lifecycle is its own control; the internal PKI guide covers running a root you own.
What the principal does not prove is analysis, not Istio documentation, but it follows from the definition. It identifies a workload, not a human, and says nothing about a request's intent. Because the identity is the service account, anything that can run a pod as that account — a compromised image, an over-broad grant to create pods — inherits it. Kubernetes RBAC over pod creation and service-account use is part of the authorization control and belongs in the same evidence pack.
One limit is specific to ambient mode. The ambient L4 policy page is explicit: "Waypoint proxies do not impersonate the identity of the source workload. Once you have introduced a waypoint to the traffic path, the destination ztunnel will see traffic with the waypoint's identity, not the source identity." A principal is the source service account only on the hop that terminated that peer. Istio's ambient tasks show the consequence: after adding a waypoint, the ztunnel-enforced policy keeps the gateway principal and also allows cluster.local/ns/default/sa/waypoint. The swap applies only to ambient sources redirected through the waypoint: the ambient data-plane page is explicit that "traffic from sidecars and gateways won't go through any waypoint proxy" yet, so those callers keep their own identity. The waypoint must also be on the path: enroll the namespace or Service first (istioctl waypoint apply --enroll-namespace, or the istio.io/use-waypoint label), or the targetRefs rule is never evaluated and a waypoint-only ztunnel ALLOW denies every caller.
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: orders-waypoint
namespace: shop
spec:
targetRefs:
- kind: Service
group: ""
name: orders
action: ALLOW
rules:
- from:
- source:
principals: ["cluster.local/ns/shop/sa/frontend"]
to:
- operation:
methods: ["GET", "POST"]
paths: ["/api/*"]
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: orders-ztunnel
namespace: shop
spec:
selector:
matchLabels:
app: orders
action: ALLOW
rules:
- from:
- source:
principals:
- "cluster.local/ns/shop/sa/shop-gateway-istio"
- "cluster.local/ns/shop/sa/waypoint"Where mesh policy fails open (or closed) without telling you
Istio documents each of these traps. None produces an error on apply.
- No policy at all. The workload allows all requests. A mesh with policies on three services is a mesh with every other service open.
- PERMISSIVE mTLS. Plaintext carries an empty identity. A positive
principalsALLOW misses it, so the request is denied once any ALLOW exists; anotPrincipalsornotNamespacesmatch, or a principal-only DENY that never fires, lets it through. A DENY withnotPrincipals: ["*"]refuses it explicitly; STRICT, which Istio's best-practices and concepts pages recommend, avoids both the rejection and the bypass. - Unscoped DENY on TCP. For a DENY rule, "missing attributes are treated as matches." A DENY that names an HTTP method, applied to a TCP port, denies all TCP traffic on that port; remove the port match and "all TCP traffic would be denied." It fails closed — the safer direction, but not the intended policy.
- Negative-match ALLOW. A mismatch widens access instead of producing a 403.
- AUDIT without a plugin. AUDIT policies "do not affect whether requests are allowed or denied", and "the request will not be audited if there are no such supporting plugins enabled." An AUDIT object with no plugin is a no-op that looks like a control.
Ambient mode adds the subtlest trap. A selector-targeted AuthorizationPolicy in ambient is enforced by the destination ztunnel, which works at L4 and cannot evaluate L7 attributes. It skips them, and the L4 policy page documents four distinct outcomes. They do not collapse into one rule, and several secondary write-ups still summarise them as "always more restrictive", which is wrong for half the table:
- ALLOW with any L7 attribute — the rule is skipped, so no ALLOW ever matches and traffic is denied. "Even if the identity of the client pod is correct, the presence of a L7 attribute causes the ztunnel to deny the connection." The
orders-allow-frontendpolicy above, applied by selector in ambient, would black-holeorders. - DENY with only L7 attributes — such as
notRequestPrincipals,pathsormethods: the rule is skipped, never matches, and traffic is allowed. A DENY written to block an admin path blocks nothing. - DENY with L4 and L7 attributes in the same entry — only the L4 part is enforced, so traffic matching it is denied, "which is broader than intended."
- DENY with L4 and L7 attributes in different entries — for example
from.source.principalsandto.operation.methods: the L7 entry is skipped, the rule never matches, and traffic is allowed.
Turning mesh policy into evidence an assessor accepts
An assessor needs three things for any access control: the intended rule, proof it is the rule actually loaded, and proof it refuses what it should. The observable for "refused" differs by enforcement point, and a script that checks for the wrong one proves nothing.
- Common to every mode: policy-as-code in git with review history (the Kyverno vs Gatekeeper guide covers admission guardrails that stop a PERMISSIVE override landing), and the
PeerAuthenticationstate across all namespaces. - Sidecar and waypoint Envoy:
istioctl x authz check(the experimentalistioctl experimental authz check) prints the AuthorizationPolicy applied to a pod "by directly checking the Envoy configuration of the pod", including the merged mesh-, namespace- and workload-level list. That is the loaded-rule proof. The Envoy RBAC filter the proxies run records allowed and denied statistics and, on a deny, putsrbac_access_denied_matched_policy[policy_name]in the response code details — the denied-by-which-rule proof. - Sidecar HTTP deny: a negative test expecting the 403. A TCP-only port has no HTTP status; there the refusal is a closed connection.
- Waypoint HTTP deny: Istio's ambient getting-started task shows the response body
RBAC: access denied. - Ztunnel deny: no 403 and no istio-proxy container to grep. The client sees the connection dropped; Istio's ambient pages show it as
command terminated with exit code 56fromcurlrun throughkubectl exec. Do not invent a ztunnel shadow-log line to look for.
#!/usr/bin/env bash
set -euo pipefail
NS=shop
MODE="${1:?usage: authz-evidence.sh sidecar|waypoint|ztunnel}"
OUT="evidence/$(date -u +%Y%m%dT%H%M%SZ)-$MODE"
mkdir -p "$OUT"
# Common: mTLS mode everywhere (look for STRICT, and for any override)
kubectl get peerauthentication -A -o yaml > "$OUT/peerauthentication.yaml"
# Negative tests run from a pod that does NOT use sa/frontend
URL=http://orders:8080/api/orders
case "$MODE" in
sidecar)
POD=$(kubectl -n "$NS" get pod -l app=orders -o jsonpath='{.items[0].metadata.name}')
istioctl x authz check "$POD.$NS" > "$OUT/authz-check.txt"
# Record every RBAC counter; do not hard-code a stat name
kubectl -n "$NS" exec "$POD" -c istio-proxy -- pilot-agent request GET stats \
| grep -i rbac > "$OUT/envoy-rbac-stats.txt" || true
# Rollout troubleshooting only (dry-run is Alpha); needs:
# istioctl proxy-config log "$POD.$NS" --level rbac:debug
kubectl -n "$NS" logs "$POD" -c istio-proxy | grep "shadow denied" || true
code=$(kubectl -n "$NS" exec deploy/curl -- curl -s -o /dev/null -w '%{http_code}' "$URL")
echo "sidecar: HTTP $code" | tee "$OUT/negative-test.txt"
[ "$code" = "403" ] || { echo "FAIL: expected 403"; exit 1; }
;;
waypoint)
body=$(kubectl -n "$NS" exec deploy/curl -- curl -s -X DELETE "$URL")
echo "waypoint: $body" | tee "$OUT/negative-test.txt"
[ "$body" = "RBAC: access denied" ] || { echo "FAIL: expected RBAC body"; exit 1; }
;;
ztunnel)
# No istio-proxy to grep and no HTTP status: pass condition is a dropped connection
set +e
kubectl -n "$NS" exec deploy/curl -- curl -s --max-time 5 "$URL"
rc=$?
set -e
echo "ztunnel: exit $rc" | tee "$OUT/negative-test.txt"
[ "$rc" -eq 56 ] || { echo "FAIL: expected exit 56"; exit 1; }
;;
*) echo "unknown mode: $MODE"; exit 2 ;;
esacSchedule it, keep the output directory, and ship the results to the same place the cluster's audit trail goes; the Wazuh audit-log pipeline guide covers retention in a SIEM you operate, and the NIST SP 800-53 mapping covers where access-control evidence sits in a control framework.
Collect the network-tier floor separately: a companion NetworkPolicy keeps the identity tier from being the only line and constrains anything outside the mesh. It is written for sidecar mode, where the CNI sees the pod's own port; ambient tunnels traffic between ztunnels differently, so check Istio's ambient networking guidance first. Gateway or cross-namespace ingress needs its own allow rule.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: shop
spec:
podSelector: {}
policyTypes:
- Ingress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: orders-from-frontend
namespace: shop
spec:
podSelector:
matchLabels:
app: orders
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080Exit ramp: keeping the control portable
The split between Istio-specific and portable decides what survives a change of mesh. The AuthorizationPolicy and PeerAuthentication CRDs, the CUSTOM/DENY/ALLOW semantics, the waypoint and ztunnel split, and the Envoy RBAC observables are Istio's. The identity model is not: principals are SPIFFE-shaped trust-domain/ns/namespace/sa/account strings, which is the format other SPIFFE-based systems consume. NetworkPolicy is upstream Kubernetes and works under any conformant CNI — the Cilium vs Calico comparison covers that choice.
- Write the intended access matrix — which service account may call which service, on which operations — as a reviewed document in git, independent of any CRD. It is the specification every mesh's policy is generated or checked against.
- Keep the NetworkPolicy floor in place even when mesh policy is comprehensive. Removing the mesh then leaves the network tier intact instead of leaving a gap.
- Keep the evidence format tool-neutral: loaded-rule output, mTLS mode listing, timestamped negative-test results. Another mesh changes the commands, not the pack.
The long game: identity outlives the network map
IP allowlists encode a topology, and topologies churn: pods reschedule, CIDRs get re-planned. A rule that says "the frontend service account may POST to the orders API" states intent that survives all of that, which is why identity-tier policy tends to age better. It also deserves the same rigour as a firewall change — reviewed, staged with dry-run, proven with a negative test, and re-proven on a schedule.
The network tier does not go away. It is the floor that holds when the mesh is misconfigured, upgraded, or removed. Two layers, each with its own evidence, built from open-source components whose behaviour is documented in public and can be read, tested and exited: an access control an assessor can accept, and one that lasts.
§FAQ/Common questions
Frequently asked
Does Istio deny traffic by default?
No. Istio allows all requests to a workload that has no authorization policy applied, and its deny-by-default behaviour applies only once a workload has at least one ALLOW policy. The usual baseline is an AuthorizationPolicy with an empty spec in each namespace, which allows nothing, plus positive-match ALLOW policies for the calls that should succeed.
Can Istio AuthorizationPolicy replace Kubernetes NetworkPolicy?
It should not. NetworkPolicy works at IP and port level, is enforced by the CNI, and constrains workloads that are not in the mesh. AuthorizationPolicy works on mTLS identity and HTTP attributes. They are different layers, and NIST SP 800-207A names both network-tier and identity-tier policies. Keep NetworkPolicy as the floor and collect evidence for each layer separately.
Why does my ALLOW policy block everything in ambient mode?
A selector-targeted policy in ambient mode is enforced by ztunnel, which cannot evaluate L7 attributes such as methods or paths. An ALLOW rule containing any L7 attribute is skipped, so nothing matches and the connection is denied even for the correct principal. Put L7 rules on a waypoint with targetRefs, and let the ztunnel policy allow the waypoint's principal alongside gateway and sidecar callers, which do not traverse it.
What evidence shows an Istio authorization policy is enforced?
The policy in git, the PeerAuthentication state showing STRICT mTLS, istioctl x authz check output showing the policy loaded into the pod's Envoy configuration, and a negative test that fails when a forbidden call succeeds. Expect a 403 from a sidecar on HTTP, the body RBAC: access denied from a waypoint, and a dropped connection (curl exit code 56) from ztunnel.
Further reading
- Default Deny, Actually: Auditing Kubernetes Network Policy
- SPIFFE/SPIRE on Kubernetes: Zero-Trust Workload Identity
- Cilium vs Calico: eBPF Zero Trust Without the Sidecar Tax
- Internal PKI with step-ca and cert-manager: Private ACME
- Kyverno vs OPA Gatekeeper: Policy as Code at Cluster Scale
- Kubernetes Audit Logs Into a SIEM You Operate: Wazuh
- NIST SP 800-53 on Kubernetes You Own: AC, AU, CM, SC, SI
Executive Briefing
Thirty minutes to clarify your infrastructure risk
Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.