Skip to content

SecurityPolicy: HTTPRoute-targeted policy silently overrides (not merges) JWT config from parent Gateway-targeted policy #8781

@zach-source

Description

@zach-source

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

  1. Create a SecurityPolicy targeting a Gateway with a jwt block (JWKS providers + claimToHeaders).
  2. 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]
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions