Summary
When an HTTPRoute has a route-level SecurityPolicy targeting it, Envoy Gateway generates an Envoy xDS config that omits the JWT configuration from any parent Gateway-level SecurityPolicy entirely. The route-level policy replaces rather than extends the gateway-level policy. There is no warning, no error — the jwt_authn HTTP filter simply has no provider rules for that route, and claimToHeaders entries never populate.
Version
Envoy Gateway v1.4.2
Steps to reproduce
- Create a
SecurityPolicy targeting a Gateway with a jwt block (JWKS providers + claimToHeaders).
- Create a second
SecurityPolicy targeting a specific HTTPRoute on that Gateway — only extAuth config, no jwt block:
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: tenant-ext-authz
namespace: api-gateway
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: tenant-api
extAuth:
grpc:
backendRefs:
- name: some-ext-authz-backend
port: 9090
headersToExtAuth: [x-jwt-tenant-id, authorization]
- Send a request to the HTTPRoute with
Authorization: Bearer <valid-jwt>.
Expected: JWT validation runs (inherited from the Gateway-level policy), x-jwt-tenant-id header is populated via claimToHeaders, then ext_authz runs with that header forwarded.
Actual: JWT validation does not run for that HTTPRoute. x-jwt-* claim headers are never populated. The ext_authz backend receives an empty x-jwt-tenant-id header and typically denies the request.
Evidence
kubectl exec envoy-proxy-pod -- curl -s localhost:19000/config_dump | jq '.configs[] | select(.[\"@type\"] | contains(\"Listener\")) | ...' on a Gateway with one Gateway-level SecurityPolicy (JWT + CORS) and several route-level SecurityPolicies (ext_authz only):
listener: my-gateway/edge/http
HTTP filter chain:
- envoy.filters.http.cors
- envoy.filters.http.jwt_authn
- envoy.filters.http.ext_authz/securitypolicy/api-gateway/tenant-ext-authz
- envoy.filters.http.router
The jwt_authn filter is present, but its providers map only contains entries for HTTPRoutes without a route-level SecurityPolicy. The route that has the route-level policy (tenant-api) is entirely missing from the providers map:
provider httproute/api-gateway/cell-registry/rule/0/match/0/*/hydra: {...}
provider httproute/api-gateway/policies/rule/0/match/0/*/hydra: {...}
provider httproute/api-gateway/features/rule/0/match/0/*/hydra: {...}
provider httproute/api-gateway/resource-types/rule/0/match/0/*/hydra: {...}
# tenant-api: ABSENT
Result: the jwt_authn filter short-circuits on the tenant-api route (no provider → nothing to validate → no claim extraction → no headers populated). The request proceeds to ext_authz with no JWT headers.
Workaround
Inline the jwt block from the Gateway-level policy into each route-level SecurityPolicy that needs JWT validation:
spec:
targetRefs: [...]
jwt: # duplicated from Gateway-level policy
optional: false
providers:
- name: hydra
issuer: https://auth.example.com
remoteJWKS: {...}
claimToHeaders: [...]
extAuth:
{...}
This works but causes config duplication and defeats the purpose of defining security at the Gateway level.
Expected behaviour
Route-level SecurityPolicies should merge with the parent Gateway-level SecurityPolicy. Per-field precedence:
- Fields present on the route-level policy take precedence (override).
- Fields absent on the route-level policy inherit from the Gateway-level policy.
Under this model, a route-level policy that defines extAuth but no jwt would inherit the Gateway-level jwt config automatically.
Alternative: if the current replace-not-merge behaviour is intentional, the validation webhook should reject route-level SecurityPolicies that omit sections defined at the Gateway level, or a warning condition should be surfaced on the SecurityPolicy.status.conditions. Silent override is the bug.
Impact
Silent security regression. Operators add route-level policies (e.g., to attach ext_authz to a specific route) and unknowingly disable JWT validation for that route. All x-jwt-* claimToHeaders stop populating, causing downstream ext_authz services and backend apps to deny requests with missing-header errors — after a reconcile window the issue looks like an auth regression or broken ext_authz rather than a policy-inheritance misfire. We spent ~20 minutes diagnosing this with /config_dump inspection before spotting that tenant-api was the only route without a provider in the jwt_authn filter config.
Related
Reported against a production setup where a Gateway-level SecurityPolicy handles CORS + JWT and a route-level SecurityPolicy attaches ext_authz for a single HTTPRoute. Inlining the jwt block into the route-level policy restores the expected behaviour, but the precedence should be documented loudly or the merge semantics should change.
Summary
When an
HTTPRoutehas a route-levelSecurityPolicytargeting it, Envoy Gateway generates an Envoy xDS config that omits the JWT configuration from any parent Gateway-levelSecurityPolicyentirely. The route-level policy replaces rather than extends the gateway-level policy. There is no warning, no error — thejwt_authnHTTP filter simply has no provider rules for that route, andclaimToHeadersentries never populate.Version
Envoy Gateway v1.4.2
Steps to reproduce
SecurityPolicytargeting aGatewaywith ajwtblock (JWKS providers +claimToHeaders).SecurityPolicytargeting a specificHTTPRouteon that Gateway — onlyextAuthconfig, nojwtblock:Authorization: Bearer <valid-jwt>.Expected: JWT validation runs (inherited from the Gateway-level policy),
x-jwt-tenant-idheader is populated viaclaimToHeaders, then ext_authz runs with that header forwarded.Actual: JWT validation does not run for that HTTPRoute.
x-jwt-*claim headers are never populated. The ext_authz backend receives an emptyx-jwt-tenant-idheader and typically denies the request.Evidence
kubectl exec envoy-proxy-pod -- curl -s localhost:19000/config_dump | jq '.configs[] | select(.[\"@type\"] | contains(\"Listener\")) | ...'on a Gateway with one Gateway-level SecurityPolicy (JWT + CORS) and several route-level SecurityPolicies (ext_authz only):The
jwt_authnfilter is present, but itsprovidersmap only contains entries for HTTPRoutes without a route-level SecurityPolicy. The route that has the route-level policy (tenant-api) is entirely missing from theprovidersmap:Result: the
jwt_authnfilter short-circuits on the tenant-api route (no provider → nothing to validate → no claim extraction → no headers populated). The request proceeds to ext_authz with no JWT headers.Workaround
Inline the
jwtblock from the Gateway-level policy into each route-level SecurityPolicy that needs JWT validation:This works but causes config duplication and defeats the purpose of defining security at the Gateway level.
Expected behaviour
Route-level SecurityPolicies should merge with the parent Gateway-level SecurityPolicy. Per-field precedence:
Under this model, a route-level policy that defines
extAuthbut nojwtwould inherit the Gateway-leveljwtconfig automatically.Alternative: if the current replace-not-merge behaviour is intentional, the validation webhook should reject route-level SecurityPolicies that omit sections defined at the Gateway level, or a warning condition should be surfaced on the
SecurityPolicy.status.conditions. Silent override is the bug.Impact
Silent security regression. Operators add route-level policies (e.g., to attach ext_authz to a specific route) and unknowingly disable JWT validation for that route. All
x-jwt-*claimToHeadersstop populating, causing downstream ext_authz services and backend apps to deny requests with missing-header errors — after a reconcile window the issue looks like an auth regression or broken ext_authz rather than a policy-inheritance misfire. We spent ~20 minutes diagnosing this with/config_dumpinspection before spotting thattenant-apiwas the only route without a provider in thejwt_authnfilter config.Related
Reported against a production setup where a Gateway-level SecurityPolicy handles CORS + JWT and a route-level SecurityPolicy attaches ext_authz for a single HTTPRoute. Inlining the
jwtblock into the route-level policy restores the expected behaviour, but the precedence should be documented loudly or the merge semantics should change.