Lesson 37 of 66 · 17 min read · Updated

Cross-Namespace Routing with ReferenceGrant and Shared Gateways

In the previous article you exposed a .NET gRPC service through storefront-gateway. So far every resource in this series has lived in a single namespace, storefront. The Gateway, the routes, the Services and the certificates all belonged to one team.

Real clusters are rarely that tidy. A platform team usually runs one shared Gateway, with one public IP address and one set of certificates. Several application teams, each in their own namespace, attach routes to it. Sooner or later one of those routes needs a Service in another team's namespace, or the Gateway needs a certificate stored somewhere else.

Gateway API was designed for exactly this situation. It makes every cross-namespace reference explicit and two-sided: the owner of the target must agree before anything can point at it. This article shows the three mechanisms that make this work: allowedRoutes, ReferenceGrant, and ListenerSet.

We will cover the following topics:

  1. Why cross-namespace references are a security problem in the first place
  2. How a shared Gateway decides which namespaces may attach routes
  3. What happens when a route points at a Service in another namespace
  4. ReferenceGrant: the target namespace's written permission
  5. Letting a Gateway use a TLS Secret from a separate certificates namespace
  6. ListenerSet: letting application teams bring their own listeners
  7. Reading the status conditions that tell you which permission is missing

Technical Requirements

  1. The minikube cluster and Envoy Gateway v1.9.2 from the earlier articles. The eg GatewayClass must exist.
  2. The kiosk-api:1.0 image built in the Services series and loaded into minikube.
  3. openssl, to create a self-signed certificate.
  4. This article creates its own Gateway in a new infra namespace, so it does not depend on storefront-gateway.

Core Concepts

Why Cross-Namespace References Are Dangerous

Namespaces are the main isolation boundary in Kubernetes. RBAC usually gives each team rights inside its own namespace and nowhere else.

Now imagine a route could point at any Service in any namespace. A developer with rights only in sandbox could write an HTTPRoute that sends public traffic to the payments Service in another namespace, or tell a Gateway to serve a certificate stored in someone else's namespace. They never touched the other namespace, yet they exposed it. This is the confused deputy problem: the gateway controller has rights everywhere, and a careless reference borrows them.

The old Ingress API avoided this by simply forbidding cross-namespace backends. Gateway API allows them, but only with consent from both sides.

The Two Handshakes

There are two different directions of reference, and each has its own consent mechanism:

ReferenceWho points at whomWho must agreeMechanism
Route attaches to a GatewayRoute (app namespace) → Gateway (infra namespace)The Gateway ownerallowedRoutes on the listener
Route sends traffic to a backendRoute (namespace A) → Service (namespace B)The Service's namespaceReferenceGrant in namespace B
Gateway uses a certificateGateway (namespace A) → Secret (namespace B)The Secret's namespaceReferenceGrant in namespace B
Listeners merged onto a GatewayListenerSet (app namespace) → GatewayThe Gateway ownerallowedListeners on the Gateway

The pattern is always the same: the thing being referenced decides. Nobody can grant themselves access to someone else's resources.

allowedRoutes on a Shared Gateway

Each listener of a Gateway says which namespaces may attach routes to it:

allowedRoutes.namespaces.fromMeaning
Same (default)Only routes in the Gateway's own namespace
AllRoutes from any namespace
SelectorOnly namespaces whose labels match selector

Same is the safe default, and it is why every earlier article kept the Gateway and its routes together in storefront. For a shared Gateway, Selector is the usual choice. The platform team labels the namespaces it has onboarded, and no other namespace can attach. If a route from an unlabelled namespace tries anyway, its status shows Accepted=False with the reason NotAllowedByListeners.

The listener can also restrict the route kinds, for example to HTTPRoute only, with allowedRoutes.kinds.

ReferenceGrant

A ReferenceGrant lives in the namespace that owns the target. It lists who may refer (from) and what they may refer to (to):

apiVersion: gateway.networking.k8s.io/v1
kind: ReferenceGrant
metadata:
name: allow-storefront-routes
namespace: orders # lives with the target
spec:
from:
- group: gateway.networking.k8s.io
kind: HTTPRoute
namespace: storefront # who may refer
to:
- group: "" # "" is the core API group
kind: Service
name: orders-api # optional: omit to allow every Service

A few rules worth remembering:

  1. from names a namespace and a kind, never an individual route. Any HTTPRoute in storefront gets the permission.
  2. to.name is optional. Leaving it out grants access to every Service in the namespace, which is rarely what you want.
  3. ReferenceGrant is one-way. A grant in orders lets storefront in; it says nothing about the opposite direction.
  4. Deleting the ReferenceGrant revokes access immediately. The route's backend becomes invalid and traffic to it stops.

ReferenceGrant was promoted to v1 and the Standard channel in Gateway API v1.5. Envoy Gateway v1.9 bundles Gateway API v1.6.1, so v1 is available. On older installations the same resource is served as v1beta1, with an identical spec.

Status Conditions That Point at the Problem

You will rarely guess wrong about which handshake is missing, because the status says so:

WhereConditionReasonMeaning
Route parents[]Accepted=FalseNotAllowedByListenersThe listener's allowedRoutes rejects this namespace or kind
Route parents[]ResolvedRefs=FalseRefNotPermittedA backendRef crosses namespaces without a ReferenceGrant
Route parents[]ResolvedRefs=FalseBackendNotFoundThe Service does not exist
Gateway listeners[]ResolvedRefs=FalseRefNotPermittedA certificateRef crosses namespaces without a ReferenceGrant

When a backend is not permitted, the route can still be accepted. Envoy Gateway keeps the other rules working and answers the broken rule with an HTTP 500, so one bad reference does not take down the whole route.

ListenerSet

allowedRoutes and ReferenceGrant cover routes and backends. One thing was still centralised: listeners. If team A needed an HTTPS listener for a.example.com with its own certificate, someone had to edit the shared Gateway. Every new hostname became a ticket for the platform team, and a Gateway can only hold 64 listeners.

ListenerSet, promoted to Standard in Gateway API v1.5, removes that bottleneck. A team creates a ListenerSet in its own namespace with one or more listeners. The controller merges them onto the parent Gateway, but only if the Gateway allows it through allowedListeners:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: shared-gateway
namespace: infra
spec:
gatewayClassName: eg
allowedListeners:
namespaces:
from: Selector
selector:
matchLabels:
shared-gateway-access: "true"
listeners:
- name: http
protocol: HTTP
port: 80
---
apiVersion: gateway.networking.k8s.io/v1
kind: ListenerSet
metadata:
name: orders-listeners
namespace: orders
spec:
parentRef:
name: shared-gateway
namespace: infra
listeners:
- name: https-orders
protocol: HTTPS
port: 443
hostname: orders.example.com
tls:
certificateRefs:
- name: orders-cert # a Secret in the orders namespace

Because the certificate now lives in the same namespace as the ListenerSet that uses it, no ReferenceGrant is needed for it. Routes attach to a ListenerSet with a parentRef of kind: ListenerSet. The Gateway itself must still have at least one listener of its own.

ListenerSet is new, and implementations are still catching up. Envoy Gateway v1.9 supports it; on any other implementation, check its conformance report before relying on it.

Hands-On: Kubernetes Commands

Show the labels on a namespace

allowedRoutes with from: Selector matches against these.

kubectl get ns --show-labels

Label a namespace for the shared Gateway

This is the platform team's "onboarding" step.

kubectl label namespace orders shared-gateway-access=true

See which routes are attached to each listener

ATTACHED ROUTES per listener lives in the Gateway status.

kubectl get gateway shared-gateway -n infra -o jsonpath='{range .status.listeners[*]}{.name}{"\t"}{.attachedRoutes}{"\n"}{end}'

Print a route's conditions in one line each

Fastest way to find NotAllowedByListeners or RefNotPermitted.

kubectl get httproute storefront-route -n storefront -o jsonpath='{range .status.parents[*].conditions[*]}{.type}{"="}{.status}{" ("}{.reason}{")\n"}{end}'

List ReferenceGrants everywhere

Grants live in the target namespaces, so search across all of them.

kubectl get referencegrants -A

Check whether ListenerSet is installed

If this returns nothing, your Gateway API CRDs predate v1.5.

kubectl get crd listenersets.gateway.networking.k8s.io

Step-by-Step Example

The Scenario

The company now has two application teams and one platform team:

  1. The platform team owns namespace infra and runs shared-gateway.
  2. The storefront team owns storefront and serves kiosk-api on storefront.local.
  3. The orders team owns orders and serves orders-api on orders.local.
  4. A third namespace, sandbox, belongs to an intern who has not been onboarded.

Then the storefront team wants storefront.local/orders to reach the orders team's Service, and security wants all certificates kept in a dedicated certs namespace. You will build this step by step and watch each permission fail before you grant it.

Step 1 — Namespaces and Labels

Create 1-namespaces.yaml. Only storefront and orders carry the access label.

apiVersion: v1
kind: Namespace
metadata:
name: infra
---
apiVersion: v1
kind: Namespace
metadata:
name: storefront
labels:
shared-gateway-access: "true"
---
apiVersion: v1
kind: Namespace
metadata:
name: orders
labels:
shared-gateway-access: "true"
---
apiVersion: v1
kind: Namespace
metadata:
name: sandbox
---
apiVersion: v1
kind: Namespace
metadata:
name: certs
kubectl apply -f 1-namespaces.yaml
kubectl get ns infra storefront orders sandbox certs --show-labels

If storefront already exists from earlier articles, kubectl apply simply adds the label.

Step 2 — The Shared Gateway

Create 2-shared-gateway.yaml. The single HTTP listener accepts HTTPRoutes only, and only from labelled namespaces.

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: shared-gateway
namespace: infra
spec:
gatewayClassName: eg
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
kinds:
- kind: HTTPRoute
namespaces:
from: Selector
selector:
matchLabels:
shared-gateway-access: "true"
kubectl apply -f 2-shared-gateway.yaml
kubectl wait --for=condition=Accepted gateway/shared-gateway -n infra --timeout=120s

Wait for Accepted, not Programmed. As you saw in the installation article, Programmed stays False with reason AddressNotAssigned until the Envoy LoadBalancer Service gets an address, which on minikube only happens while minikube tunnel runs. Routing through the port-forward works either way.

On Docker Desktop's built-in kind cluster, LoadBalancer Services are published on localhost, but only one Service can own a given port. If storefront-gateway from earlier articles still holds port 80, the shared Gateway's Service stays <pending> and the Gateway is not Programmed. That is harmless here, because every test goes through a port-forward.

Step 3 — The Applications

Both teams run the same kiosk-api:1.0 image, so the response's servedByPod field tells you which namespace answered. Create 3-apps.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
name: kiosk-api
namespace: storefront
spec:
replicas: 2
selector:
matchLabels:
app: kiosk-api
template:
metadata:
labels:
app: kiosk-api
spec:
containers:
- name: kiosk-api
image: kiosk-api:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
---
apiVersion: v1
kind: Service
metadata:
name: kiosk-api
namespace: storefront
spec:
selector:
app: kiosk-api
ports:
- port: 80
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
namespace: orders
spec:
replicas: 2
selector:
matchLabels:
app: orders-api
template:
metadata:
labels:
app: orders-api
spec:
containers:
- name: orders-api
image: kiosk-api:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
---
apiVersion: v1
kind: Service
metadata:
name: orders-api
namespace: orders
spec:
selector:
app: orders-api
ports:
- port: 80
targetPort: 8080
kubectl apply -f 3-apps.yaml

If kiosk-api is already running in storefront from earlier articles, the apply just updates it.

Step 4 — Each Team Attaches Its Own Route

Create 4-team-routes.yaml. Each route names the Gateway with an explicit namespace, because the Gateway is no longer in the route's own namespace.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: storefront-route
namespace: storefront
spec:
parentRefs:
- name: shared-gateway
namespace: infra
hostnames:
- storefront.local
rules:
- backendRefs:
- name: kiosk-api
port: 80
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: orders-route
namespace: orders
spec:
parentRefs:
- name: shared-gateway
namespace: infra
hostnames:
- orders.local
rules:
- backendRefs:
- name: orders-api
port: 80
kubectl apply -f 4-team-routes.yaml
kubectl get gateway shared-gateway -n infra -o jsonpath='{range .status.listeners[*]}{.name}{"\t"}{.attachedRoutes}{"\n"}{end}'
http 2

Two routes from two namespaces share one listener. Start a port-forward to the shared Gateway's Envoy Service in a separate terminal:

export ENVOY_SERVICE=$(kubectl get svc -n envoy-gateway-system \
--selector=gateway.envoyproxy.io/owning-gateway-namespace=infra,gateway.envoyproxy.io/owning-gateway-name=shared-gateway \
-o jsonpath='{.items[0].metadata.name}')
kubectl -n envoy-gateway-system port-forward service/${ENVOY_SERVICE} 8888:80
curl -s -H "Host: storefront.local" http://localhost:8888/ | jq .servedByPod
"kiosk-api-7c9f8d5b6d-4m2xq"
curl -s -H "Host: orders.local" http://localhost:8888/ | jq .servedByPod
"orders-api-5d8b7c6f9c-p7k2v"

Step 5 — The Unlabelled Namespace Is Rejected

The intern in sandbox tries to grab a hostname on the shared Gateway. Create 5-sandbox-route.yaml:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: sandbox-route
namespace: sandbox
spec:
parentRefs:
- name: shared-gateway
namespace: infra
hostnames:
- sandbox.local
rules:
- backendRefs:
- name: whatever
port: 80
kubectl apply -f 5-sandbox-route.yaml
kubectl get httproute sandbox-route -n sandbox -o jsonpath='{range .status.parents[*].conditions[*]}{.type}{"="}{.status}{" ("}{.reason}{")\n"}{end}'
Accepted=False (NotAllowedByListeners)
ResolvedRefs=False (BackendNotFound)

The second line only says that the whatever Service does not exist. The first line is the one that matters.

Kubernetes accepted the object, because the intern has rights in sandbox. The Gateway refused to attach it, because the platform team has not onboarded sandbox. No Envoy configuration was generated for it.

curl -s -o /dev/null -w "%{http_code}\n" -H "Host: sandbox.local" http://localhost:8888/
404

Step 6 — A Cross-Namespace Backend Without Permission

The storefront team wants storefront.local/orders to reach the orders team's Service directly. Update storefront-route in 4-team-routes.yaml so it has two rules:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: storefront-route
namespace: storefront
spec:
parentRefs:
- name: shared-gateway
namespace: infra
hostnames:
- storefront.local
rules:
- matches:
- path:
type: PathPrefix
value: /orders
filters:
- type: URLRewrite # orders-api serves /, not /orders
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /
backendRefs:
- name: orders-api
namespace: orders
port: 80
- backendRefs:
- name: kiosk-api
port: 80

The URLRewrite filter strips /orders before the request reaches orders-api. Without it, the backend would receive /orders, a path the sample app does not serve, and answer with an empty 404.

kubectl apply -f 4-team-routes.yaml
kubectl get httproute storefront-route -n storefront -o jsonpath='{range .status.parents[*].conditions[*]}{.type}{"="}{.status}{" ("}{.reason}{")\n"}{end}'
Accepted=True (Accepted)
ResolvedRefs=False (RefNotPermitted)

The route is still attached, so the rest of storefront.local keeps working. Only the rule with the forbidden reference is broken:

curl -s -o /dev/null -w "%{http_code}\n" -H "Host: storefront.local" http://localhost:8888/
200
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: storefront.local" http://localhost:8888/orders
500

Look at the full message once; it names the exact reference that was refused:

kubectl describe httproute storefront-route -n storefront

Step 7 — The Orders Team Grants Access

Only the owner of orders can fix this. Create 7-referencegrant-orders.yaml in the orders namespace:

apiVersion: gateway.networking.k8s.io/v1
kind: ReferenceGrant
metadata:
name: allow-storefront-routes
namespace: orders
spec:
from:
- group: gateway.networking.k8s.io
kind: HTTPRoute
namespace: storefront
to:
- group: ""
kind: Service
name: orders-api
kubectl apply -f 7-referencegrant-orders.yaml
kubectl get httproute storefront-route -n storefront -o jsonpath='{range .status.parents[*].conditions[*]}{.type}{"="}{.status}{" ("}{.reason}{")\n"}{end}'
Accepted=True (Accepted)
ResolvedRefs=True (ResolvedRefs)
curl -s -H "Host: storefront.local" http://localhost:8888/orders | jq .servedByPod
"orders-api-5d8b7c6f9c-p7k2v"

Notice that nothing changed on the route. The status flipped because the target namespace changed its mind. Now revoke it, to prove the grant is live:

kubectl delete referencegrant allow-storefront-routes -n orders
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: storefront.local" http://localhost:8888/orders
500
kubectl apply -f 7-referencegrant-orders.yaml

Step 8 — A Certificate from Another Namespace

Security wants every TLS Secret in the certs namespace, where only they have access. Create a self-signed certificate and store it there:

openssl req -x509 -nodes -newkey rsa:2048 -days 365 \
-keyout storefront.key -out storefront.crt \
-subj "/CN=storefront.local" -addext "subjectAltName=DNS:storefront.local"
kubectl create secret tls storefront-tls -n certs --cert=storefront.crt --key=storefront.key

Add an HTTPS listener to 2-shared-gateway.yaml that references the Secret across namespaces. Append it under listeners:

- name: https
protocol: HTTPS
port: 443
hostname: storefront.local
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: storefront-tls
namespace: certs
allowedRoutes:
kinds:
- kind: HTTPRoute
namespaces:
from: Selector
selector:
matchLabels:
shared-gateway-access: "true"
kubectl apply -f 2-shared-gateway.yaml
kubectl get gateway shared-gateway -n infra -o jsonpath='{range .status.listeners[?(@.name=="https")].conditions[*]}{.type}{"="}{.status}{" ("}{.reason}{")\n"}{end}'
ResolvedRefs=False (RefNotPermitted)
Programmed=False (Invalid)

Envoy Gateway v1.9.2 reports RefNotPermitted and refuses to program the listener. Other implementations may use InvalidCertificateRef for certificate problems, but ResolvedRefs=False on the https listener is always the signal to look for.

The Gateway is in infra and the Secret is in certs, so certs must agree. Create 8-referencegrant-certs.yaml:

apiVersion: gateway.networking.k8s.io/v1
kind: ReferenceGrant
metadata:
name: allow-infra-gateways
namespace: certs
spec:
from:
- group: gateway.networking.k8s.io
kind: Gateway
namespace: infra
to:
- group: ""
kind: Secret
name: storefront-tls
kubectl apply -f 8-referencegrant-certs.yaml
kubectl get gateway shared-gateway -n infra -o jsonpath='{range .status.listeners[?(@.name=="https")].conditions[*]}{.type}{"="}{.status}{" ("}{.reason}{")\n"}{end}'
Programmed=True (Programmed)
Accepted=True (Accepted)
ResolvedRefs=True (ResolvedRefs)

ResolvedRefs is now True. Attach the storefront route to the new listener too by adding a second parent to storefront-route:

parentRefs:
- name: shared-gateway
namespace: infra
sectionName: http
- name: shared-gateway
namespace: infra
sectionName: https
kubectl apply -f 4-team-routes.yaml

Port-forward the HTTPS port in another terminal (export ENVOY_SERVICE there as in Step 4) and test. --resolve makes curl send the right SNI and Host while connecting to localhost:

kubectl -n envoy-gateway-system port-forward service/${ENVOY_SERVICE} 8443:443
curl -sk --resolve storefront.local:8443:127.0.0.1 https://storefront.local:8443/ | jq .servedByPod
"kiosk-api-7c9f8d5b6d-4m2xq"

-k is needed only because the certificate is self-signed. With cert-manager, as in the HTTPS article, the Secret would be issued into certs and the same ReferenceGrant would apply.

Step 9 — Optional: Team-Owned Listeners with ListenerSet

First check that the ListenerSet CRD is installed:

kubectl get crd listenersets.gateway.networking.k8s.io

If it is, the platform team can stop adding listeners for every team. Allow ListenerSets from onboarded namespaces by adding this block to the Gateway's spec in 2-shared-gateway.yaml:

allowedListeners:
namespaces:
from: Selector
selector:
matchLabels:
shared-gateway-access: "true"

The orders team then creates its own certificate in its own namespace and a ListenerSet for orders.local. Create 9-orders-listenerset.yaml:

apiVersion: gateway.networking.k8s.io/v1
kind: ListenerSet
metadata:
name: orders-listeners
namespace: orders
spec:
parentRef:
name: shared-gateway
namespace: infra
listeners:
- name: https-orders
protocol: HTTPS
port: 443
hostname: orders.local
tls:
certificateRefs:
- name: orders-tls
openssl req -x509 -nodes -newkey rsa:2048 -days 365 \
-keyout orders.key -out orders.crt \
-subj "/CN=orders.local" -addext "subjectAltName=DNS:orders.local"
kubectl create secret tls orders-tls -n orders --cert=orders.crt --key=orders.key
kubectl apply -f 2-shared-gateway.yaml
kubectl apply -f 9-orders-listenerset.yaml
kubectl get listenerset orders-listeners -n orders

Finally, point orders-route at the ListenerSet as an additional parent:

parentRefs:
- name: shared-gateway
namespace: infra
- group: gateway.networking.k8s.io
kind: ListenerSet
name: orders-listeners
kubectl apply -f 4-team-routes.yaml
curl -sk --resolve orders.local:8443:127.0.0.1 https://orders.local:8443/ | jq .servedByPod
"orders-api-5d8b7c6f9c-p7k2v"

The orders team added a hostname, a certificate and a listener without touching any file the platform team owns, and without any ReferenceGrant, because nothing crossed a namespace except the ListenerSet's attachment, which allowedListeners approved. Both HTTPS listeners share port 443 and are told apart by hostname (SNI).

Step 10 — Clean Up

Deleting the namespaces removes almost everything created in this article. storefront-route lives in storefront, which stays, so delete it explicitly.

kubectl delete httproute storefront-route -n storefront
kubectl delete ns infra orders sandbox certs
kubectl label namespace storefront shared-gateway-access-
rm -f storefront.key storefront.crt orders.key orders.crt

If you created storefront only for this article, delete it as well.

Summary

  1. Gateway API allows cross-namespace references, but always with consent from the side being referenced. This prevents the confused deputy problem that made Ingress forbid them entirely.
  2. A shared Gateway's listener controls attachment with allowedRoutes: Same by default, All, or Selector on namespace labels. A refused route shows Accepted=False (NotAllowedByListeners).
  3. A route pointing at a Service in another namespace needs a ReferenceGrant in the Service's namespace. Without it the route shows ResolvedRefs=False (RefNotPermitted) and Envoy Gateway answers that rule with 500, while the other rules keep working.
  4. A Gateway using a TLS Secret from another namespace needs a ReferenceGrant in the Secret's namespace, with from.kind: Gateway.
  5. ReferenceGrant names a namespace and kind in from; always set to.name so you grant one resource, not the whole namespace. Deleting the grant revokes access immediately.
  6. ReferenceGrant is gateway.networking.k8s.io/v1 since Gateway API v1.5 (v1beta1 on older CRDs).
  7. ListenerSet (Standard since v1.5) lets application teams add their own listeners and certificates to a shared Gateway, approved through allowedListeners.

In the next and final article of this series we put everything together and migrate an existing Ingress NGINX setup to Gateway API with ingress2gateway.