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:
- An
EnvoyProxythat points the GeoIP filter at a MaxMind database. - A
ClientTrafficPolicythat tells Envoy which address to treat as the client. - A
SecurityPolicywhose 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:
| |
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.type–MaxMindis the only provider today.maxMind.cityDbSource– the local path to a GeoLite2 or GeoIP2 City database.
It does not matter whether you configure
cityDbSourceorcountryDbSourcefor country matching. Envoy Gateway accepts either for acountryrule, and the City database already contains the country ISO code. Onlyregionandcityrules strictly require the City database;asn,ispandanonymouseach 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:
| |
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-suppliedX-Forwarded-Forbefore original-IP detection runs.
Trusting one forwarded hop would, on its own, let a client forge an
X-Forwarded-Forheader and geo-spoof itself into the allowlist. Removing the header inearlyRequestHeaderscloses 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 cleanerdirectSourceIPdetection 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:
| |
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:
| |
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:
| |
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:
| |
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
sectionNamescoping 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:
| |
And the control plane should be publishing again — the count here must be zero:
| |
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
SecurityPolicyattached to an individualHTTPRoute— 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 countryauthorizationblock is repeated there. Audit which hosts have their own policies before assuming blanket coverage. directSourceIPis 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 thexForwardedForworkaround 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.