MastertheMesh
istio · ambient · authorization
Field guide

Client IP allowlists per namespace, enforced at the waypoint

TO
Tom O'Rourke
EMEA Field CTO · Solo.io

In ambient, ingress traffic skips the waypoint by default: a bound waypoint handles east-west, while an ingress gateway routes straight to the pods. So if you want to filter client IPs at the edge, do it at the ingress gateway. Moving that filter to the waypoint is worth it only when each namespace has to own its own allowlist, enforced next to the workload. This is what it costs when you do.

ingress-use-waypoint remote.ip AuthorizationPolicy targetRefs: Service externalTrafficPolicy Ambient

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:

  1. 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.
  2. 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.
  3. 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:

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 writeResolves 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.

LabelSet onWhat 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.

NORTH-SOUTH, WITH istio.io/ingress-use-waypoint: true External client Ingress gateway Waypoint App pod NORTH-SOUTH, DEFAULT External client Ingress gateway App pod waypoint not in the path EAST-WEST, WHENEVER A WAYPOINT IS BOUND In-mesh client Waypoint App pod via ztunnel

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.

Peered multicluster, and tooling that reads the value back. Solo Enterprise for Istio 1.30 carries a low-impact breaking change here: on the auto-generated 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.
The ingress gateway does not have to be in the mesh. The endpoint swap happens in the gateway's configuration, so a gateway in its own namespace that is not enrolled in ambient still sends the request to the waypoint. Routing to a Service in another namespace needs the usual Gateway API 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.

  1. 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.
  2. 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 policyNorth-southEast-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 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.

Spoofing still does not work. The gateway appends the address it actually saw after whatever the client sent, and the waypoint reads the last entry, so a request arriving with its own 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.

NORTH-SOUTH Ingress gateway productpage-ingress use-waypoint: waypoint ingress-use-waypoint: true Waypoint EAST-WEST In-mesh client productpage no waypoint labels app: productpage the same pods, selected by both Services ztunnel, direct any in-cluster caller can use the plain Service and skip the allowlist and that hole cannot be closed, because requiring the waypoint identity at the pod kills east-west

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 favourAgainst 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.

So requirement 3 and the request it usually arrives with are the same decision made two opposite ways. Keeping east-west off the waypoint, and having the workload refuse anything that did not come through the waypoint, cannot both be true. Requirements 1 and 2 hold either way, so the question worth settling first is whether the east-west hop is a measured problem or an assumed one, and that hop does not put internal traffic under the edge rule.

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:

CheckCommand shapeResult
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:

  1. 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-For value; if the request is absent, the ingress-use-waypoint label has not taken effect.
  2. Is the client IP the one you think? The X-Forwarded-For field in that same log line is the value the policy matches on. If it reads as a node address, fix externalTrafficPolicy before touching the policy.
  3. Is the policy programmed? A denial is reported in the waypoint access log as rbac_access_denied_matched_policy followed by the namespace and policy name, so you can tell which rule fired rather than guessing.
  4. 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.
Give the config time to land. Policy edits took roughly 15 to 25 seconds to reach the waypoint and ztunnel on the test cluster. A probe fired immediately after 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.

See also. Upstream reference for the labels and traffic types: istio.io · configure waypoint proxies. Condition keys including remote.ip: istio.io · authorization policy conditions.