Envoy, Istio and agentgateway
These gateways have no in-process plugin system. Their authorisation extension point is Envoy's external authorization filter, so the enforcement point is coaz-pep as a gRPC service, and configuring a route means two things: pointing the filter at it, and passing the route's knobs as context_extensions.
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
One binary serves all three gateways; only the attachment differs. It is a one-to-one port of the Kong plugin, so both send the PDP identical evaluation requests:
| Kong plugin | Envoy-family equivalent |
|---|---|
per-route plugin config | ext_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 |
| RFC 9470 step-up challenges | Envoy DeniedHttpResponse: status, WWW-Authenticate and body pass through unchanged |
X-Auth-* upstream headers | OkHttpResponse.headers |
The per-route knobs
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 knobs at all is a plain REST resource server with require_token off.
| Key | Default | Meaning |
|---|---|---|
pep_label | coaz-pep | Identifies this PEP in challenges, logs and X-PDP-PEP. |
style | rest | rest (resource server) or mcp (MCP edge). |
require_token | false | Deny without a readable access token. |
require_dpop | false | Enforce 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_login | false | Deny without a valid X-User-Token, with a login challenge. |
stepup_scope | — | Scope demanded for stepup_action. |
stepup_action | make_payment | The action the step-up applies to. |
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_token | false | Send the raw token to the PDP as context.access_token. Only over a TLS, authenticated PDP connection. |
pdp_layers | PDP_LAYERS | Ordered 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_mode | PDP_FAIL_MODE | closed 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_defaults | true | Decide 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_only | false | Refuse tools that declare only the superseded coaz: true mapping. |
user_token_subject | principal | Whose 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_identity | true | Also send subject.identity beside subject.id. Set "false" once policies read subject.id. |
Plain Envoy
The filter, configured directly, with per-route knobs 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 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 knobs. This is gateways/envoy/istio/coaz-pep.yaml from 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.0 # 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', '/metrics']
---
# 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
The trap. AuthorizationPolicy carries no knobs, so without the EnvoyFilter every request reaches the PEP with defaults. Fine for a plain REST service. Not fine for an MCP edge, where style and mcp_upstream_url have no sensible default. Either patch them in as above, or give the MCP edge its own provider pointing at its own coaz-pep service.
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. Route those two 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 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 knobs.
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.