johanneskueber.com

Country-based access control with Envoy Gateway (and the TCP-listener trap)

The goal was simple: everything on the public gateway should only answer requests originating from one country. Most of the self-hosted services behind it have no business being reachable from the other side of the planet, and a country allowlist at the edge cuts the background noise of credential-stuffing and vulnerability scanners down to almost nothing before a request ever reaches an application.

Envoy Gateway can do this natively since v1.8 with the MaxMind GeoIP filter and an authorization SecurityPolicy. The configuration is small. Getting it to actually apply, however, took a detour through a translator bug that freezes the entire control plane — so this article covers both the working configuration and the problems encountered on the way there.

As with anything at this layer, the exact resources are specific to the environment. The topology here is an internet-facing edge gateway doing TLS passthrough into a second “public” gateway where TLS is terminated, and the real client IP is carried end to end over PROXY protocol. Adjust for your own ingress path.

How the pieces fit together

Three resources cooperate to make a country decision:

  1. An EnvoyProxy that points the GeoIP filter at a MaxMind database.
  2. A ClientTrafficPolicy that tells Envoy which address to treat as the client.
  3. A SecurityPolicy whose authorization rules match on the resolved country.
flowchart LR
    client([Client]) -->|PROXY protocol| edge[Edge gateway]
    edge -->|real source IP| envoy[Public Envoy]
    envoy --> geoip["GeoIP filter<br/>resolves country"]
    geoip --> rbac["RBAC filter<br/>allow / deny"]
    rbac -->|allowed| route[HTTPRoute → app]
    rbac -->|denied| deny[403]

The GeoIP filter writes the resolved country into an internal request header. The RBAC filter, generated from the SecurityPolicy, matches that header against the allowlist. Everything hinges on Envoy resolving the real client IP, which is where the ClientTrafficPolicy comes in.

The configuration

The GeoIP provider

The GeoIP database is attached to the proxy through the EnvoyProxy resource that parameterises the gateway:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
  name: public
spec:
  geoIP:
    provider:
      type: MaxMind
      maxMind:
        cityDbSource:
          local:
            path: /etc/envoy/geoip/GeoLite2-City.mmdb

The database file itself lives on a shared read-only volume and is copied into the proxy pod by an init container, with a scheduled job refreshing it on MaxMind’s build cadence. That plumbing is not interesting; the only thing the filter needs is a readable .mmdb at the configured path.

Field reference:

  • provider.typeMaxMind is the only provider today.
  • maxMind.cityDbSource – the local path to a GeoLite2 or GeoIP2 City database.

It does not matter whether you configure cityDbSource or countryDbSource for country matching. Envoy Gateway accepts either for a country rule, and the City database already contains the country ISO code. Only region and city rules strictly require the City database; asn, isp and anonymous each need their own dedicated database.

Telling Envoy who the client is

A country lookup is only as trustworthy as the IP it runs against. Because the edge passes the original client address down over PROXY protocol, that address is already sitting on the downstream connection — but the authorization filter refuses to run until the ClientTrafficPolicy explicitly declares a client-IP detection method:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: ClientTrafficPolicy
metadata:
  name: public
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: public
  proxyProtocol:
    optional: false
  clientIPDetection:
    xForwardedFor:
      numTrustedHops: 1
  headers:
    earlyRequestHeaders:
      remove:
        - X-Forwarded-For

Field reference:

  • proxyProtocol.optional: false – require PROXY protocol from the upstream edge, so the real client IP is restored onto the connection.
  • clientIPDetection.xForwardedFor.numTrustedHops: 1 – the value the GeoIP feature expects.
  • headers.earlyRequestHeaders.remove: [X-Forwarded-For] – strip any client-supplied X-Forwarded-For before original-IP detection runs.

Trusting one forwarded hop would, on its own, let a client forge an X-Forwarded-For header and geo-spoof itself into the allowlist. Removing the header in earlyRequestHeaders closes that window: with no forwarded header present, Envoy falls back to the connection’s source address — the real client IP delivered by PROXY protocol. A cleaner directSourceIP detection mode exists, but only from Envoy Gateway v1.9 onward; on v1.8 the strip-and-trust-one-hop combination is the pragmatic equivalent.

The authorization policy

Finally, the rule itself — deny by default, allow one country:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
  name: public-geo-allow
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: public
      sectionName: https
  authorization:
    defaultAction: Deny
    rules:
      - name: allow-de
        action: Allow
        principal:
          clientIPGeoLocations:
            - country: DE

Field reference:

  • targetRefs[].sectionName: https – attach the policy to the HTTPS listener only, not the whole gateway. This single line is the difference between a working setup and a frozen control plane; the next section explains why.
  • authorization.defaultAction: Deny – reject anything no rule allows.
  • rules[].principal.clientIPGeoLocations[].country – the ISO country code to allow.

Denied requests get a 403 at the gateway before any application sees them, and still appear in the traffic telemetry with their origin country, which is handy for confirming the filter is doing something.

The problems along the way

The configuration above is the destination. The route there had three detours worth documenting, because none of them produce an obvious error message.

“Requires clientIPDetection to be configured”

The first attempt targeted the gateway with the SecurityPolicy but left the ClientTrafficPolicy untouched. The policy was rejected outright:

authorization clientIPGeoLocations requires
ClientTrafficPolicy.spec.clientIPDetection to be configured

This one is honest and self-explanatory: geo matching needs a declared client-IP source. Adding the clientIPDetection block from above fixes it. Onwards.

The whole control plane freezes

With clientIPDetection in place, the SecurityPolicy reported Accepted: True. And yet nothing was being blocked — requests from every country sailed through with 200. Worse, unrelated changes to other gateways stopped taking effect too.

The controller log explained the second symptom:

skipped publishing xds resources: failed to translate xds ir
  invalid RBAC.Matcher: embedded message failed validation
  caused by: invalid Matcher_MatcherList_FieldMatcher.Predicate: value is required

That is the critical detail. When translation of the xDS snapshot fails, Envoy Gateway does not publish a partial config — it publishes nothing and keeps serving the last good snapshot to every proxy it manages. A single malformed policy therefore freezes configuration propagation for all gateways at once. The SecurityPolicy status says Accepted, the applications keep running on stale config, and there is no loud failure anywhere obvious — the only tell is that new changes silently stop landing.

The malformed part is an RBAC matcher with an empty predicate. The question was: why does a straightforward country: DE rule generate an empty predicate?

It is the TCP listener

The public gateway does not only serve HTTPS. It also carries a raw TCP listener for git over SSH:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
listeners:
  - name: git
    protocol: TCP
    port: 22
    # ...
  - name: https
    protocol: HTTPS
    port: 443
    hostname: "*.web.example.com"
    # ...

A geo rule cannot be expressed for a TCP listener — there is no HTTP request, and therefore no header to match a country against. When the SecurityPolicy targets the whole gateway, Envoy Gateway nonetheless tries to build the RBAC matcher for every listener, including the TCP one, and produces a matcher with no predicate for it. That empty predicate is what fails proto validation and takes the entire snapshot down with it.

The fix is to never let the policy touch the TCP listener. targetRefs supports a sectionName, so scoping the policy to the https listener keeps the geo RBAC off the TCP listener entirely:

1
2
3
4
5
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: public
      sectionName: https

Confirming it without breaking production again

Re-applying a policy that might re-freeze the control plane is not a pleasant test loop. Envoy Gateway ships the same translation logic as a CLI, so the whole thing can be checked offline, against nothing but a file:

1
egctl x translate --from gateway-api --to xds --file input.yaml

Feeding it the rendered manifests — gateway with both listeners, the ClientTrafficPolicy, an HTTPRoute, a throwaway TLS secret so the HTTPS listener resolves, and the SecurityPolicy — reproduces the failure exactly when the policy targets the whole gateway, and produces a clean snapshot with a valid country matcher the moment sectionName: https is added.

Two things fell out of running that comparison across versions:

  • The empty-predicate bug is present when targeting the gateway on every version tested, including a v1.9 release candidate. This is not something a version bump fixes; the sectionName scoping is the actual fix.
  • With sectionName: https, the generated RBAC contains exactly what it should — an allow rule matching the internal country header against the allowlisted code, and a default deny — on the installed version, no upgrade required.

Verifying the result

Once the scoped policy is applied, the status should report acceptance against the specific listener:

1
2
3
kubectl -n envoy-gateway-system get securitypolicy public-geo-allow \
  -o jsonpath='{.status.ancestors[0].conditions[0].reason}'
# Accepted

And the control plane should be publishing again — the count here must be zero:

1
2
3
kubectl -n envoy-gateway-system logs deploy/envoy-gateway --since=2m \
  | grep -c 'skipped publishing'
# 0

The real proof is a request from each side of the fence. Probing the same host from an out-of-country location and from an in-country location should return a clean split:

from a foreign probe   → 403 Forbidden
from an in-country probe → 200 OK

That is the whole point: one country in, everything else stopped at the gateway with a 403, before a single application container is involved.

For the future

A few things are worth keeping in mind once this is running:

  • Route-level policies win. A SecurityPolicy attached to an individual HTTPRoute — for example an OIDC policy in front of a specific app — replaces this listener-level policy for that route rather than layering on top of it. Any host protected by its own route-level policy is therefore not geo-blocked unless the country authorization block is repeated there. Audit which hosts have their own policies before assuming blanket coverage.
  • directSourceIP is the cleaner client-IP mode and removes the need for the trust-one-hop-and-strip dance, but it only lands in Envoy Gateway v1.9. Until then the xForwardedFor workaround is correct as long as the header strip stays in place.

The configuration is a dozen lines across three resources. The lesson is the one line that is not obvious: on a gateway that mixes HTTP and TCP listeners, an authorization policy must be scoped to the HTTP listener, or it will take the entire control plane down with it.


stat /posts/2026-07-27-envoy-gateway-geoip-country-blocking/

2026-07-27: Initial publication of the article