Where the question comes from
The question arrives as a data-path request: can ingress traffic go
through the waypoint without east-west going through it? The filtering
itself is the easy half. An ingress gateway allows or denies client IP ranges
directly, which is a well-trodden path with an upstream task page of its own:
Ingress Access Control,
covering remoteIpBlocks against ipBlocks,
numTrustedProxies, and the load-balancer cases. If the
requirement is no more than these ranges may reach this hostname,
that is the answer, and the next section is where to stop reading.
Three shapes cover it. Which one you want depends on what sits in front of the gateway:
Nothing in front of the gateway, or an L4 load balancer that keeps the source address. Match the connection itself, which no header can change. First make sure the address survives kube-proxy:
kubectl -n bookinfo patch svc bookinfo-gateway-istio \
-p '{"spec":{"externalTrafficPolicy":"Local"}}'
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: edge-client-cidr-filter
namespace: bookinfo # namespace of the ingress gateway
spec:
targetRefs:
- kind: Gateway
group: gateway.networking.k8s.io
name: bookinfo-gateway
action: DENY
rules:
- from:
- source:
notIpBlocks: # deny anything outside the allowed ranges
- 75.60.241.209/32
- 10.20.0.0/16
An L7 load balancer or CDN terminates first, so the client address arrives
in X-Forwarded-For and the connection belongs to the proxy.
Identical policy, one field different, and it only reads the right entry
once the trusted-hop count is set on the next tab.
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: edge-client-cidr-filter
namespace: bookinfo
spec:
targetRefs:
- kind: Gateway
group: gateway.networking.k8s.io
name: bookinfo-gateway
action: DENY
rules:
- from:
- source:
notRemoteIpBlocks: # the X-Forwarded-For derived address
- 75.60.241.209/32
- 10.20.0.0/16
when: key: remote.ip with notValues is the same
matcher written as a condition, so use whichever reads better.
How many proxies in front of the gateway you are prepared to believe.
Count the ones that genuinely exist: one cloud load balancer is
1, a CDN in front of that load balancer is 2. On
a Gateway API gateway it goes on the Gateway, and the control plane copies
it onto the generated pod:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: bookinfo-gateway
namespace: bookinfo
spec:
gatewayClassName: istio
infrastructure:
annotations:
proxy.istio.io/config: '{"gatewayTopology":{"numTrustedProxies":1}}'
listeners:
- name: http
hostname: "productpage.example.com"
port: 80
protocol: HTTP
Set it mesh-wide instead with
meshConfig.defaultConfig.gatewayTopology.numTrustedProxies,
though per-gateway is usually what you want, because different gateways sit
behind different things.
What makes a team look past it is not capability. It is how the config has to be written, and who ends up owning it:
- Ownership. The policy lives in the gateway's namespace, so the platform team holds every tenant's ranges, and a tenant cannot change their own without editing shared config.
- Configuration effort. At the gateway the rule is evaluated before routing has picked a workload, so it has to be expressed in hostnames and paths, and someone has to keep that mapping in step with the namespaces behind it as services come and go.
- Enforcement next to the workload. The control sits at the last hop in front of the app, so a change at the edge cannot silently widen it.
Start at the gateway
Ingress traffic does not pass through a waypoint by default, so the ingress gateway is where an edge filter naturally belongs. Two things decide whether the policy above means what you think: which field you match on, and whether the address it reads is really the client.
Which field to match on
The two IP fields ask different questions, and the names do not make that obvious:
ipBlocksasks who opened this connection to the gateway. It is the address on the socket, so nothing the client writes in a header can change it.remoteIpBlocksasks who the forwarding headers say started this request, trusting a set number of hops ofX-Forwarded-For.
With nothing in front of the gateway both give the same answer. Once a proxy
is in front, they give different answers, and only one of them is the client.
Take a request from 203.0.113.5 that reaches the gateway through
a CDN and a cloud load balancer. Each hop appends the address it received
from, so the gateway sees this:
client 203.0.113.5 -> CDN 198.51.100.7 -> cloud LB 192.0.2.9 -> gateway
connection opened by: 192.0.2.9
X-Forwarded-For: 203.0.113.5, 198.51.100.7
| What you write | Resolves to, for that request |
|---|---|
ipBlocks |
192.0.2.9, your own load balancer |
remoteIpBlocks, numTrustedProxies unset |
192.0.2.9, the same thing, because zero hops are trusted |
remoteIpBlocks, numTrustedProxies: 1 |
198.51.100.7, the CDN. One hop back, still not the client |
remoteIpBlocks, numTrustedProxies: 2 |
203.0.113.5, the client. Two proxies in front, so two hops back |
So the count is simply how many proxies sit between the client and the
gateway, and the failure it prevents is an allowlist that quietly permits your
own infrastructure instead of your users.
when: key: remote.ip with notValues is the same
thing as remoteIpBlocks written as a condition, so use whichever
reads better.
Scoping it per hostname
A shared gateway usually needs different ranges per hostname, which means
adding a to match:
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: edge-client-cidr-filter
namespace: bookinfo
spec:
targetRefs:
- kind: Gateway
group: gateway.networking.k8s.io
name: bookinfo-gateway
action: DENY
rules:
- to:
- operation:
hosts: ["productpage.example.com"]
ports: ["80"] # see the warning below
from:
- source:
notRemoteIpBlocks:
- 75.60.241.209/32
That earns a warning from the control plane if you leave the port out:
Warning: configured AuthorizationPolicy will deny all traffic to TCP ports
under its scope due to the use of only HTTP attributes in a DENY rule;
it is recommended to explicitly specify the port
A hosts match is an HTTP attribute, and a DENY rule built only
from HTTP attributes denies every non-HTTP port in its scope. The IP fields
do not trigger it, because a source match is not an HTTP attribute, so the
warning arrives only once you scope per hostname. Name the port and it goes
away.
For a single team running a handful of hostnames this is the whole job. It is the three points above, ownership, config effort and where enforcement sits, that push a multi-tenant platform to the waypoint instead.
How ingress traffic reaches a waypoint
Two separate labels are involved, and conflating them is where most of the confusion starts.
| Label | Set on | What it does |
|---|---|---|
istio.io/use-waypoint |
Namespace or Service | Binds a waypoint to the service. This is what makes ztunnel send east-west, service-addressed traffic to the waypoint. |
istio.io/ingress-use-waypoint |
Service, falling back to Namespace | Opts the north-south path in. With it set, an ingress gateway's endpoints for that service become the waypoint's HBONE port instead of the pod IPs. |
Without the second label, an ingress gateway routes straight to the pods and the waypoint never sees the request, even though the waypoint is bound and is handling east-west traffic for the same service. That is the default, and it is the behaviour that makes people reach for a duplicate service.
The north-south label only adds the top path. It cannot remove the bottom one.
The namespace fallback on istio.io/ingress-use-waypoint matters
operationally: a platform team can set it once on the namespace and every
service in that namespace is covered, rather than labelling each Service and
hoping the next one gets labelled too.
WorkloadEntry resources in peered multicluster
environments, istio.io/ingress-use-waypoint is now tracked as a
label rather than an annotation. If anything of yours reads
that value off a WorkloadEntry, point it at labels.
ReferenceGrant, nothing waypoint-specific.
Why this cannot live at L4, next to the pod
The instinct from sidecar mode is to put the rule on the workload itself with a
selector, which in ambient means ztunnel enforces it. ztunnel does
match source IPs, so the policy is accepted and looks right. It just cannot
express what you want, because the address it compares is the source of the
connection it received, and the client's connection ended at the gateway.
So at the pod, the source is the ingress gateway or the waypoint, never the
external client. Client IP is only recoverable where
X-Forwarded-For is parsed, which is an L7 hop. That is why the
nearest point to the workload that can hold this rule is its waypoint, and it
is also why the selector policies in this guide are used to
restrict which in-cluster identity may reach the pod, rather than to
filter client ranges.
The trade-off you cannot design away
The ingress opt-in is additive. It adds the north-south hop to a waypoint that is already bound; it cannot take the east-west hop away. There is no ingress-only binding: for service-addressed traffic ztunnel sends to the bound waypoint, and that decision does not vary by caller. So the honest framing is not how do I get ingress-only enforcement, it is which of these two do I want:
| One Service, waypoint on both directions | Shadow Service for ingress only | |
|---|---|---|
| Client IP policy on the north-south path | Enforced | Enforced |
| East-west takes the waypoint hop | Yes | No |
| Workload can be restricted to the waypoint's identity | Yes, so the policy cannot be bypassed | No, that restriction breaks east-west |
| Objects per exposed service | Two labels and one policy | An extra Service, and every route has to point at it |
The third row is the one that decides it. If the point of moving enforcement next to the workload is that nothing can reach the workload without passing the control, only the first column delivers that. In the second column the real Service still resolves to the pods for anything already inside the cluster, and requiring the waypoint's identity at the workload takes east-west down with it.
East-west takes the hop, not the rule
The objection to putting east-west through a waypoint is usually two worries wearing one coat. They are worth separating, because only one of them is real.
- The path. Binding a waypoint does change where east-west traffic goes. It now passes through the waypoint pod. That is a real cost: a hop.
- The rule. It does not change which policies apply to that traffic. A rule fires only on requests that match every condition in it.
The client-IP rule has two conditions joined by AND: the caller must be the ingress gateway's identity, and the client address must be outside the allowed ranges. An internal caller is some other workload, so the first condition is false, the rule does not match, and that request is never judged on its IP at all. It passes through the waypoint untouched by the allowlist.
Measured on one waypoint, with the allowed range deliberately set to exclude the caller, so a rule that fires is visible as a 403:
| The policy | North-south | East-west |
|---|---|---|
With from.source.principals set to the ingress gateway |
403, refused as intended | 200, never evaluated |
| The same rule with that clause removed | 403 | 403, internal traffic broken |
So the second row is the mistake to avoid, and the first clause is what
prevents it. Drop the principals clause and internal traffic gets
judged against a list of public addresses. It has no forwarded header for the
waypoint to read, so the address compared is an internal one, which is not in
your public range, and every internal call fails. Keep the clause and
east-west costs you a hop, not a policy.
The shape I would run
The labels reference a waypoint, they do not create one, so deploy it first.
The name is not fixed: it is whatever you call the Gateway, and the label has
to match it. waypoint is only the conventional name, because that
is what istioctl waypoint apply uses by default.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: waypoint # the label below must match this name
namespace: bookinfo
spec:
gatewayClassName: istio-waypoint
listeners:
- name: mesh
port: 15008
protocol: HBONE
Then bind it at namespace level and opt the ingress path in at the same level. Two labels, set once by whoever owns the namespace:
apiVersion: v1
kind: Namespace
metadata:
name: bookinfo
labels:
istio.io/dataplane-mode: ambient
istio.io/use-waypoint: waypoint # must match the Gateway name above
istio.io/ingress-use-waypoint: "true"
Then the allowlist itself, per service, living in the namespace that owns the
service. targetRefs on an AuthorizationPolicy
accepts a Service, and a Service-scoped policy is enforced at
that service's waypoint, which is what gives per-workload granularity from a
single shared waypoint:
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: ingress-client-cidr-filter
namespace: bookinfo
spec:
targetRefs:
- kind: Service
group: ""
name: productpage
action: DENY
rules:
- from:
- source:
principals:
- cluster.local/ns/bookinfo/sa/bookinfo-gateway-istio
when:
- key: remote.ip
notValues:
- 75.60.241.209/32
Per service, or per namespace
A waypoint policy can be targeted two ways, and the choice is a scope
decision. kind: Service, as above, applies to that one service.
kind: Gateway pointed at the waypoint applies to
every service behind that waypoint, which is the right shape
when the allowlist is a namespace-wide rule rather than a per-service one:
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: ingress-client-cidr-filter
namespace: bookinfo
spec:
targetRefs:
- kind: Gateway
group: gateway.networking.k8s.io
name: waypoint # the waypoint's Gateway, not the ingress gateway
action: DENY
rules:
- from:
- source:
principals:
- cluster.local/ns/bookinfo/sa/bookinfo-gateway-istio
when:
- key: remote.ip
notValues:
- 75.60.241.209/32
Both forms were tested and behave identically on the north-south path. Use the Gateway form for a namespace-wide rule and the Service form when one service needs different ranges from its neighbours.
What that policy refuses, precisely: traffic meeting both
conditions, arriving from the ingress gateway's identity and carrying a
client address outside the allowed ranges. Traffic between workloads is not
matched at all, so it is unaffected, and anything the rule does not match is
allowed as far as this policy goes, because the action is DENY.
Two details are doing real work. The principals entry is what
scopes the rule to the ingress path, and it is not optional:
remove it and internal traffic breaks, because it
gets judged against a list of public addresses it can never match. It also
means the rule only trusts a forwarded header on the one path where your own
gateway wrote it. And remote.ip, not source.ip, is
the key that means the original client. At a waypoint the peer of the
connection is always the gateway, so source.ip and
ipBlocks describe the gateway pod and are no use here.
The gateway's service account name comes from the gateway itself, so read it back rather than guessing:
kubectl -n bookinfo get deploy bookinfo-gateway-istio \
-o jsonpath='{.spec.template.spec.serviceAccountName}'
Making it unbypassable
Finally, the part that makes it an enforcement point rather than a suggestion. Restrict the workload to the waypoint's identity, so a caller that addresses the pod directly is refused by ztunnel:
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: productpage-waypoint-only
namespace: bookinfo
spec:
selector:
matchLabels:
app: productpage
action: ALLOW
rules:
- from:
- source:
principals:
- my-trust-domain/ns/bookinfo/sa/waypoint
Use your mesh's own trust domain in that policy, which is not necessarily
cluster.local. Read it off the mesh before you write it:
kubectl -n istio-system get cm istio -o jsonpath='{.data.mesh}' | grep trustDomain
What changes about the client IP at a waypoint
The edge rules all still apply,
because the waypoint is reading what the gateway recorded. One thing does
change, and it is the reason the waypoint policies in this guide use
remote.ip rather than the field pair that suits a gateway.
A waypoint sets use_remote_address: false, so it takes the last
entry of X-Forwarded-For, which is the address the gateway
appended. That makes remote.ip and
remoteIpBlocks the original client, and it makes
ipBlocks useless here: the connection the waypoint sees comes
from the gateway, so direct_remote_ip is always a gateway pod
whatever the client did.
X-Forwarded-For is still judged on its real address.
If east-west really must skip the waypoint
Sometimes the extra hop is genuinely unacceptable, and the answer is a second Service that carries the labels while the original stays plain. Routes point at the shadow Service, east-west keeps using the real one.
Only the shadow Service carries the labels, so only the ingress path is pulled through the waypoint.
The shadow Service itself is ordinary. The selector is the important line: it matches the same pods as the real Service.
apiVersion: v1
kind: Service
metadata:
name: productpage-ingress
namespace: bookinfo
labels:
app: productpage
istio.io/use-waypoint: waypoint
istio.io/ingress-use-waypoint: "true"
spec:
selector:
app: productpage
ports:
- name: http
port: 9080
targetPort: 9080
Point the allowlist policy's targetRefs at
productpage-ingress and the north-south control works exactly
as before, with east-west never entering the waypoint. What you give up is
the workload restriction, so add the guard rail that at least removes the
edge-side hole: deny the ingress gateway's identity at the workload, so a
route pointed straight at the real Service later on fails instead of quietly
skipping the allowlist.
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: no-direct-ingress-to-workload
namespace: bookinfo
spec:
selector:
matchLabels:
app: productpage
action: DENY
rules:
- from:
- source:
principals:
- my-trust-domain/ns/bookinfo/sa/bookinfo-gateway-istio
What it buys, and what it costs
| In its favour | Against it |
|---|---|
| East-west never enters the waypoint, so no extra hop and no shared config path. An ingress-policy mistake cannot reach internal traffic. | It cannot be made unbypassable. Any in-cluster caller can address the real Service and never meet the allowlist. |
| Ingress enforcement is identical to the single-Service option: verified 200 inside the range, 403 outside, and a spoofed forwarded header does not pass. | An extra Service per exposed app, and every route must point at the shadow. A route added later against the real Service silently skips the control. |
| Per-service opt-in, so you can do this for one latency-sensitive service and leave the rest on the single-Service shape. | Two objects to keep in step: ports, selectors, appProtocol, and anything else that drifts when one is edited and the other is not. |
With that in place the intended path still works, a route aimed at the real Service returns 503, and east-west is untouched. In-cluster callers can still reach the workload without the allowlist, which is inherent to the pattern rather than a configuration mistake.
The three requirements, answered
These are the requirements that usually arrive with this question. Each answer links to the part of this guide that shows the configuration.
1. Can each namespace decide its own source ranges, independently?
Yes, and this is the case the waypoint exists for. Give the namespace a waypoint and opt its ingress path in with the two labels, set once on the namespace. The waypoint is chosen by the destination service, so the rule runs after routing has picked the workload, which is exactly the property a shared gateway cannot give you. The policy itself is an ordinary object in that namespace, owned by whoever owns the namespace, so one team changing its ranges touches nothing else: the shape I would run.
2. Can it be per workload, without hostname and path gymnastics?
Yes, and you pick the scope.
targetRefs to a Service covers that one service;
targetRefs to the waypoint's Gateway covers every
service behind it, so a namespace-wide rule is a single object:
per service, or per namespace. Because the request
has already been matched to a service before the rule runs, nothing has to map
hostnames or paths back to workloads, and there is no shared config to keep in
step as services come and go.
3. Can enforcement sit at the workload, the zero-trust way?
Yes, but only in the version where east-west also goes through the waypoint. Two things to know before agreeing on this one.
First, the sidecar instinct does not carry over: this cannot live at L4 next to the pod, because the only address available there is the previous hop, not the client. The nearest point to the workload that can hold a client-IP rule is its waypoint.
Second, enforced in the zero-trust sense means unbypassable, which means the workload accepting only the waypoint's identity: making it unbypassable. That closes every other path in, which is the point, and it is also why it only works when every path in goes through the waypoint. In the shadow-Service variant the same restriction takes east-west down with it.
Setting it up end to end
The order that works, with two tenants behind one shared gateway so the per-namespace part is visible rather than asserted. Everything below was run as written.
1. One shared gateway, tenants own their routes
The gateway lives in its own namespace and accepts routes from the tenant
namespaces, so each team keeps its own HTTPRoute next to the
Service it exposes:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: shared-gateway
namespace: istio-ingress
spec:
gatewayClassName: istio
listeners:
- name: pay
hostname: pay.example.com
port: 80
protocol: HTTP
allowedRoutes:
namespaces:
from: All
Then preserve the client address, which nothing else works without:
kubectl -n istio-ingress patch svc shared-gateway-istio \
-p '{"spec":{"externalTrafficPolicy":"Local"}}'
2. Per tenant: a waypoint, two labels, one policy
Deploy the waypoint first, because the labels reference it:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: waypoint
namespace: payments
spec:
gatewayClassName: istio-waypoint
listeners:
- name: mesh
port: 15008
protocol: HBONE
---
apiVersion: v1
kind: Namespace
metadata:
name: payments
labels:
istio.io/dataplane-mode: ambient
istio.io/use-waypoint: waypoint # matches the Gateway above
istio.io/ingress-use-waypoint: "true"
The tenant's HTTPRoute points at its own Service and parents onto
the shared gateway. Then one policy per namespace, naming the waypoint:
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: ingress-client-cidr-filter
namespace: payments
spec:
targetRefs:
- kind: Gateway
group: gateway.networking.k8s.io
name: waypoint
action: DENY
rules:
- from:
- source:
principals:
- cluster.local/ns/istio-ingress/sa/shared-gateway-istio
when:
- key: remote.ip
notValues:
- 10.1.0.0/16
Repeat for the second tenant with its own range. That is the whole per-tenant footprint: a waypoint, two labels, one policy, and no hostname anywhere in the policy.
3. The proof worth running in front of someone
Same client, same gateway, two namespaces, opposite outcomes:
GW=$(kubectl -n istio-ingress get gateway shared-gateway -o jsonpath='{.status.addresses[0].value}')
curl -s -o /dev/null -w 'payments %{http_code}\n' -H 'Host: pay.example.com' http://$GW/get
curl -s -o /dev/null -w 'hr %{http_code}\n' -H 'Host: hr.example.com' http://$GW/get
payments 200
hr 403
Each namespace decided that for itself. Then confirm nothing collateral moved:
| Check | Command shape | Result |
|---|---|---|
| East-west unaffected | exec deploy/client -- curl api.payments:8000 |
200 |
| A spoofed forwarded header cannot pass | add -H 'X-Forwarded-For: 203.0.113.5' |
unchanged |
| Pod-IP bypass, with the workload restriction applied | exec deploy/client -- curl <podIP>:8080 |
refused |
4. Add a service and change nothing
This is the part that answers "we would have to track every hostname". With the
policy targeting the waypoint, a Service and route added to that namespace
afterwards is covered with no policy edit at all. Verified by adding a second
service and route after the policy existed: it was refused on the same rule,
with the policy still at generation: 1, and the new Service
carrying no labels of its own.
Proving it works
Four signals, in the order worth checking:
- Is the waypoint in the path at all? Turn on access logging and look for the request in the waypoint's log. A north-south request shows the external hostname and an
X-Forwarded-Forvalue; if the request is absent, theingress-use-waypointlabel has not taken effect. - Is the client IP the one you think? The
X-Forwarded-Forfield in that same log line is the value the policy matches on. If it reads as a node address, fixexternalTrafficPolicybefore touching the policy. - Is the policy programmed? A denial is reported in the waypoint access log as
rbac_access_denied_matched_policyfollowed by the namespace and policy name, so you can tell which rule fired rather than guessing. - Did the workload restriction land? ztunnel's config dump shows the principal exactly as it will be matched, which is the fastest way to catch the trust-domain problem before it looks like a networking fault.
kubectl apply reads the
previous config, which is a very convincing way to conclude that a working
design does not work. Loop the check rather than sleeping once.
What was verified, and where
Every behaviour above was run on a two-node cluster on the Solo
distribution of Istio 1.30.3 in ambient mode, with an
istio Gateway on a load-balancer address, an
istio-waypoint waypoint, and a client workload in a separate
ambient namespace for east-west. Confirmed live: the default north-south
bypass, with the waypoint access log showing no new lines for an external
request while the same waypoint was handling east-west; the gateway-level
filter allowing and denying correctly with no waypoint in the path, in both
the ipBlocks and remoteIpBlocks forms, and again
when scoped to a hostname and port; numTrustedProxies reaching
the gateway pod through spec.infrastructure.annotations and
landing as xff_num_trusted_hops in its Envoy config, along with
the spoof it enables when set higher than the proxies that exist;
the waypoint policy in both its Service and its
Gateway targeted forms; the namespace-level
ingress-use-waypoint opt-in; the
address change between Cluster and Local
external traffic policy, allow and deny outcomes on the per-Service policy,
the spoofed-header case, the refused pod-IP bypass under the workload
restriction, the shadow Service keeping east-west out of the waypoint, that
the same workload restriction breaks east-west in that variant, and the
guard rail returning 503 for a route aimed at the real Service.
Every API used here is upstream Istio: the two waypoint labels, Gateway API,
and AuthorizationPolicy. Nothing in this guide depends on an
enterprise-only field. The supporting behaviour was read from the upstream
Istio and ztunnel source as well as observed, in particular that the ingress
path is an endpoint-level swap to the waypoint's HBONE port, that
targetRefs accepts Service, ServiceEntry, Gateway and
GatewayClass, and that the waypoint derives the remote address from the
forwarded header rather than the connection.
remote.ip:
istio.io · authorization policy conditions.