IDPartnersidp-auth-peps · reference
gateways/envoy/ · YAML

Envoy, Istio and agentgateway

Envoy, Istio and agentgateway connect to coaz-pep through the ext_authz gRPC API. Configure the gateway to call the service. Pass per-route settings as context_extensions. The examples below cover each gateway.

There is no screen for this component. Configuration is the gateway's own YAML, shown here in full for each of the three, and coaz-pep's environment, on its page.

How it attaches

client ──▶ gateway ──ext_authz gRPC──▶ coaz-pep ──AuthZEN──▶ PDP
              │                            │
              ▼ PERMIT                     ▼ DENY
        upstream service            401/403 + WWW-Authenticate, or a
                                    JSON-RPC error for MCP — passed through verbatim

The same coaz-pep service supports all three gateways. The table compares its configuration and responses with the Kong integration.

Kong pluginEnvoy-family equivalent
per-route plugin configext_authz context_extensions
claim extraction (sub, act.sub, scope, cnf.jkt, acr)same logic, in Go
DPoP sender constraint (RFC 9449)same, verified in process
refusals and challenges (RFC 6750, RFC 9470), the same from every PEPEnvoy DeniedHttpResponse: status, WWW-Authenticate and body pass through unchanged
X-Auth-* upstream headersOkHttpResponse.headers

The per-route settings

Read from context_extensions on every request. Every value is a string, so booleans are "true" and "false". Anything not set takes the default, and a route with no settings at all is a plain REST resource server with require_token off.

KeyDefaultMeaning
pep_labelcoaz-pepIdentifies this PEP in challenges, logs and X-PDP-PEP.
stylerestrest (resource server) or mcp (MCP edge).
require_tokenfalseDeny without a readable access token.
require_dpopfalseEnforce the RFC 9449 sender constraint on every request. Off, a DPoP-bound token is still checked, and one presented as a bearer token is rejected.
require_user_loginfalseDeny without a valid X-User-Token, with a 401 login_required challenge.
stepup_scope—The scope a 403 insufficient_scope challenge names when the PDP asks for a step-up without naming one.
stepup_actionmake_paymentRead and not used: which actions need a step-up is the PDP's decision.
mcp_upstream_url—The MCP server whose tools/list declares per-tool mappings. Without it every tool call gets the binding's default mapping.
resource—The protected resource's identifier (RFC 8707), the key PDP discovery starts from. MCP routes default to mcp_upstream_url; REST routes without it use the static PDP.
forward_access_tokenfalseSend the raw token to the PDP as context.access_token. Only over a TLS, authenticated PDP connection.
pdp_layersPDP_LAYERSOrdered PDPs to ask, comma-separated: static, resource, or a PDP identifier (which must be on PDP_ALLOWLIST), each optionally suffixed fail-open or fail-closed. Every layer must permit.
fail_modePDP_FAIL_MODEclosed or open for this route's layers that say nothing for themselves. Open marks the permit with X-PDP-Fail-Open; a deny or a refusal never opens.
coaz_defaultstrueDecide every MCP method: a tool's declared mapping, otherwise the binding's default one; an unknown method is denied. "false" keeps the old pass-through for everything but declared tools - an explicit opt-out.
coaz_v2_onlyfalseRefuse tools that declare only the superseded coaz: true mapping.
user_token_subjectprincipalWhose X-User-Token counts: the principal's own login, or with pdp also an approver's, for the PDP to judge with user_sub.
legacy_subject_identitytrueAlso send subject.identity beside subject.id. Set "false" once policies read subject.id.

Plain Envoy

The filter, configured directly, with per-route settings in ExtAuthzPerRoute. This is a complete listener for two routes: a REST API with discovery, and an MCP edge with COAZ.

static_resources:
  listeners:
    - name: edge
      address: { socket_address: { address: 0.0.0.0, port_value: 8000 } }
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                stat_prefix: edge
                http_filters:
                  - name: envoy.filters.http.ext_authz
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
                      transport_api_version: V3
                      failure_mode_allow: false        # fail closed
                      with_request_body: { max_request_bytes: 1048576, allow_partial_message: false }
                      grpc_service:
                        envoy_grpc: { cluster_name: coaz_pep }
                        timeout: 2s
                  - name: envoy.filters.http.router
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
                route_config:
                  virtual_hosts:
                    - name: api
                      domains: ["api.bank.example"]
                      routes:
                        # the resource's public documents: served by coaz-pep, not decided by it
                        - match: { prefix: "/.well-known/oauth-protected-resource" }
                          route: { cluster: coaz_pep_http }
                          typed_per_filter_config:
                            envoy.filters.http.ext_authz:
                              "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
                              disabled: true
                        - match: { path: "/.well-known/openid-federation" }
                          route: { cluster: coaz_pep_http }
                          typed_per_filter_config:
                            envoy.filters.http.ext_authz:
                              "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
                              disabled: true
                        # the API
                        - match: { prefix: "/" }
                          route: { cluster: bank_api }
                          typed_per_filter_config:
                            envoy.filters.http.ext_authz:
                              "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
                              check_settings:
                                context_extensions:
                                  pep_label: "PEP#2 (Bank API edge)"
                                  style: rest
                                  require_token: "true"
                                  require_dpop: "true"
                                  stepup_scope: "banking:payments:transfer"
                                  stepup_action: "make_payment"
                                  resource: "https://api.bank.example"
                                  forward_access_token: "true"
                                  pdp_layers: "https://estate-pdp.bank.example fail-open, resource"
                    - name: mcp
                      domains: ["mcp.bank.example"]
                      routes:
                        - match: { prefix: "/mcp" }
                          route: { cluster: bank_mcp }
                          typed_per_filter_config:
                            envoy.filters.http.ext_authz:
                              "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
                              check_settings:
                                context_extensions:
                                  pep_label: "PEP#1 (MCP edge)"
                                  style: mcp
                                  require_token: "true"
                                  require_user_login: "true"
                                  mcp_upstream_url: "http://bank-mcp:8090/mcp"
                                  coaz_defaults: "true"
  clusters:
    - name: coaz_pep
      type: STRICT_DNS
      typed_extension_protocol_options:
        envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
          "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
          explicit_http_config: { http2_protocol_options: {} }
      load_assignment:
        cluster_name: coaz_pep
        endpoints: [{ lb_endpoints: [{ endpoint: { address: { socket_address: { address: coaz-pep, port_value: 9191 } } } }] }]
    - name: coaz_pep_http
      type: STRICT_DNS
      load_assignment:
        cluster_name: coaz_pep_http
        endpoints: [{ lb_endpoints: [{ endpoint: { address: { socket_address: { address: coaz-pep, port_value: 9192 } } } }] }]
    - name: bank_api
      type: STRICT_DNS
      load_assignment:
        cluster_name: bank_api
        endpoints: [{ lb_endpoints: [{ endpoint: { address: { socket_address: { address: bank-api, port_value: 8070 } } } }] }]
    - name: bank_mcp
      type: STRICT_DNS
      load_assignment:
        cluster_name: bank_mcp
        endpoints: [{ lb_endpoints: [{ endpoint: { address: { socket_address: { address: bank-mcp, port_value: 8090 } } } }] }]

Istio

Istio speaks the same gRPC. Four pieces, in order: the secret, deployment and service, a mesh extension provider naming it (which lives in the istio ConfigMap and cannot be a CRD), an AuthorizationPolicy with action: CUSTOM on the workloads to guard, and an EnvoyFilter if a route needs settings. This is abridged from gateways/envoy/istio/coaz-pep.yaml in the repository.

# kubectl -n istio-system edit configmap istio   (under data.mesh)
extensionProviders:
  - name: coaz-pep
    envoyExtAuthzGrpc:
      service: coaz-pep.authz.svc.cluster.local
      port: 9191
      includeRequestBodyInCheck:
        maxRequestBytes: 1048576
        allowPartialMessage: false
apiVersion: v1
kind: Namespace
metadata:
  name: authz
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: coaz-pep
  namespace: authz
  labels: { app: coaz-pep }
spec:
  replicas: 2 # fail-closed puts this in the request path: do not run one
  selector:
    matchLabels: { app: coaz-pep }
  template:
    metadata:
      labels: { app: coaz-pep }
    spec:
      containers:
        - name: coaz-pep
          image: ghcr.io/id-partners/coaz-pep:0.4.2 # pin a release, never :latest
          ports:
            - { name: grpc, containerPort: 9191 }
            - { name: http, containerPort: 9192 }
          env:
            # coaz-pep will not start without these — see its page
            - name: AUTHZEN_URL
              value: http://authzen-adapter.authz.svc.cluster.local:8080
            - name: AUTHZEN_API_KEY
              valueFrom:
                secretKeyRef: { name: coaz-pep, key: authzen-api-key }
            - name: CHECK_API_TOKEN
              valueFrom:
                secretKeyRef: { name: coaz-pep, key: check-api-token }
            - name: MCP_UPSTREAM_ALLOWLIST
              value: http://bank-mcp.default.svc.cluster.local:8090
            - name: ACCESS_TOKEN_JWKS_URL
              value: https://as.example.com/.well-known/jwks.json
            - name: ACCESS_TOKEN_ISSUER
              value: https://as.example.com
            - name: ACCESS_TOKEN_AUDIENCE
              value: https://api.example.com
            - name: USER_TOKEN_AUDIENCE
              value: banking-app
          readinessProbe:
            httpGet: { path: /readyz, port: http }
          livenessProbe:
            httpGet: { path: /healthz, port: http }
          resources:
            requests: { cpu: 50m, memory: 64Mi }
            limits: { memory: 256Mi }
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: [ALL] }
---
apiVersion: v1
kind: Service
metadata:
  name: coaz-pep
  namespace: authz
spec:
  selector: { app: coaz-pep }
  ports:
    - { name: grpc, port: 9191, targetPort: 9191, appProtocol: grpc }
    - { name: http, port: 9192, targetPort: 9192 }
---
# Guard one workload. `action: CUSTOM` hands the decision to the named provider;
# `rules` narrows WHICH requests are sent for a decision, it does not decide them.
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: bank-api-authzen
  namespace: default
spec:
  selector:
    matchLabels: { app: bank-api }
  action: CUSTOM
  provider:
    name: coaz-pep
  rules:
    - to:
        - operation:
            notPaths: ['/healthz']
---
# Per-route knobs. AuthorizationPolicy has no field for context_extensions, so with the
# three resources above every request arrives with the defaults: style=rest,
# require_token=false, no COAZ. Enough for a REST service whose tokens a
# RequestAuthentication validates; NOT enough for an MCP edge.
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: coaz-pep-context-mcp
  namespace: default
spec:
  workloadSelector:
    labels: { app: bank-mcp }
  configPatches:
    - applyTo: HTTP_ROUTE
      match:
        context: SIDECAR_INBOUND
        routeConfiguration:
          vhost:
            route:
              name: default
      patch:
        operation: MERGE
        value:
          typed_per_filter_config:
            envoy.filters.http.ext_authz:
              '@type': type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
              check_settings:
                context_extensions:
                  pep_label: 'PEP#1 (MCP edge)'
                  style: mcp
                  require_token: 'true'
                  require_dpop: 'false'
                  require_user_login: 'true'
                  mcp_upstream_url: http://bank-mcp.default.svc.cluster.local:8090/mcp

AuthorizationPolicy has no field for per-route settings. Without the EnvoyFilter, the PEP receives default settings. MCP routes require explicit style and mcp_upstream_url values. Set them through the filter shown above or through a dedicated coaz-pep provider.

agentgateway

agentgateway (solo.io) attaches the same service through its extAuthz policy, and the per-route context map is delivered as context_extensions. This is the repository's gateways/envoy/agentgateway/config.yaml.template: one gateway fronting an MCP edge and a bank API, each with its own PEP label. The __PLACEHOLDER__ tokens are substituted at container start by docker-entrypoint.sh, so the same image runs in compose and on Railway.

binds:
- port: 8000
  listeners:
  - protocol: HTTP
    routes:
    - name: mcp-route
      matches:
      - path:
          pathPrefix: /mcp
      policies:
        extAuthz:
          host: __EXTAUTHZ_HOST__          # coaz-pep:9191
          failureMode: deny               # fail closed if the PEP is unreachable
          includeRequestBody:
            maxRequestBytes: 1048576      # the PEP decides every JSON-RPC message
            allowPartialMessage: false    # never a truncated body
          protocol:
            grpc:
              context:
                pep_label: "PEP#1 (MCP edge)"
                style: "mcp"
                require_token: "true"
                require_dpop: "false"
                require_user_login: "true"
                mcp_upstream_url: "http://__BANK_MCP_HOST__/mcp"
      backends:
      - host: __BANK_MCP_HOST__

    - name: bank-route
      matches:
      - path:
          pathPrefix: /bank
      policies:
        extAuthz:
          host: __EXTAUTHZ_HOST__
          failureMode: deny
          includeRequestBody:
            maxRequestBytes: 1048576      # the PEP inspects payment bodies
            allowPartialMessage: false
          protocol:
            grpc:
              context:
                pep_label: "PEP#2 (Bank API edge)"
                style: "rest"
                require_token: "true"
                require_dpop: "false"
                stepup_scope: "banking:payments:transfer"
                stepup_action: "make_payment"
        urlRewrite:
          path:
            prefix: /                     # /bank/payments -> /payments upstream
      backends:
      - host: __BANK_API_HOST__

The well-known documents

When coaz-pep is the federation entity for the resource it fronts (FEDERATION_ENTITY_ID on its page), it serves {path}/.well-known/openid-federation and /.well-known/oauth-protected-resource{path} on its HTTP port, with the key set the second advertises as jwks_uri at /.well-known/oauth-protected-resource{path}/jwks.json. Route those paths, on the resource's host, to that port as an ordinary upstream with the ext_authz filter disabled for them, as the plain Envoy sample does. They are public documents; the trust controller fetches the first to onboard the resource.

Whole bodies only

Set allow_partial_message: false. coaz-pep decides every MCP message and reads payment bodies, so it must see the body the upstream will. With partial messages allowed, a body over the limit reaches the PEP truncated while the upstream receives all of it - the PEP refuses a body Envoy marks partial on an MCP route with a 413, but the right configuration never sends one. Set max_request_bytes above your largest legitimate request, and leave pack_as_bytes as you like: the PEP reads whichever field the body arrives in.

The sample manifest in gateways/envoy/istio/coaz-pep.yaml also carries a PodDisruptionBudget, topology spread, a 30 second grace period for the SIGTERM drain, and a NetworkPolicy that lets only the gateways reach :9191 - a caller there chooses the route's settings.

Failure mode

Always fail closed at the gateway. failure_mode_allow: false in Envoy, failureMode: deny in agentgateway, and Istio's CUSTOM action already denies when the provider is unreachable. A PEP that fails open at the gateway is not a PEP. The place to decide that an individual policy layer may be skipped when its PDP is down is coaz-pep's pdp_layers and fail_mode, which mark every such permit with X-PDP-Fail-Open; the gateway's own failure mode is about coaz-pep itself being unreachable, and that must deny.