A gateway is usually told by hand where its Policy Decision Point is, and which scopes and acr each API requires. Here it is told neither. The API publishes both at its well-known address, /.well-known/oauth-protected-resource. A gateway that reads it finds the PDP and hands the PDP the API's own requirements, and a federation decides whether the API is to be believed. These are OpenID AuthZEN enforcement points for Envoy, Istio and agentgateway, for Kong, for PingAccess, and for Node, built around that.
It asks the API. The API's RFC 9728 metadata names the PDP that decides for it; the PDP's own AuthZEN metadata names its endpoints. Nothing about either is configured on the gateway.
The gateway hands the PDP the API's document as published, with the endpoint hit and the token. The PDP reads the scopes and the acr the API requires out of it. The gateway compares nothing.
Per API, per gateway, today's enforcement point is told five things by hand. With discovery it is told one, and the other four are read from where they are published, so a change is made once, by whoever owns it.
| The gateway needs to know | Today | With discovery |
|---|---|---|
| Which API it is protecting | configured | configured — the API's identifier is the one thing it is told |
| Which PDP decides for that API | configured (AUTHZEN_URL) | read from the API's metadata, or from the federation |
| Where the PDP's endpoints are | assumed (/access/v1/evaluation) | read from the PDP's metadata |
| Which scopes and acr each route requires | configured on the gateway, compared by the gateway | published by the API, enforced by the PDP |
| Who is allowed to say any of the above | whoever edits the gateway | the federation's policy, when the API is a member |
Two documents, each at a well-known address, each published by the party that owns the fact. The gateway reads them in order and caches them; a PDP that publishes no metadata gets AuthZEN's default paths.
{
"resource": "https://api.bank-a.example",
"authzen_policy_decision_points": [
"https://pdp.bank-a.example/tenants/bank-a"
],
"scopes_supported": ["accounts:read", "payments:write"],
"acr_values_required": ["urn:idp:loa:mfa"]
}
{
"policy_decision_point":
"https://pdp.bank-a.example/tenants/bank-a",
"access_evaluation_endpoint":
"https://pdp.bank-a.example/tenants/bank-a/access/v1/evaluation",
"access_evaluations_endpoint":
"https://pdp.bank-a.example/tenants/bank-a/access/v1/evaluations"
}
Every endpoint is AuthZEN's own name. What differs between PDPs is the base it hangs off: an identifier may carry a path, and /tenants/bank-a is what a multi-tenant deployment looks like. A gateway told a URL by hand assumes the identifier's base; a gateway that read the metadata follows the PDP when it moves. authzen_policy_decision_points is a parameter this project mints, since neither RFC 9728 nor AuthZEN defines one; it is a single constant in each codebase.
The gateway does not read scopes_supported and compare it to the token. It forwards the API's document verbatim, says where it came from, adds the endpoint hit, and, when the route allows, the raw token. The PDP does the matching, next to everything else it knows about the client and the risk.
"context": {
"resource_metadata_source": "rfc9728",
"resource_metadata": {
"resource": "https://api.bank-a.example",
"scopes_supported": ["accounts:read", "payments:write"],
"acr_values_required": ["urn:idp:loa:mfa"],
"authzen_policy_decision_points": [ … ]
},
"request": { "method": "POST", "path": "/payments" },
"access_token": "eyJ…"
}
{
"decision": false,
"context": {
"reason": "this resource requires acr [urn:idp:loa:mfa]
(per its rfc9728 metadata); the token was
authenticated at urn:idp:loa:password"
}
}
The gateway judges nothing. A gateway that compared scopes would be policy in two places, and the dumber one would win. The PDP's reason names the requirement it read and the document it read it from, so an operator can see the second discovery in every deny.
The value being discovered is who may decide access to this API. An API that names its own PDP can name a permissive one, and a document over TLS cannot protect the thing it asserts. So there are two sources, and one outranks the other.
A federated API has to publish two things: a signed entity configuration that the trust controller fetches to onboard it, and its RFC 9728 metadata. The gateway is the API's public face, so it publishes both, and the split is deliberate: the gateway holds a key, the controller holds the policy.
{
"iss": "https://api.bank-a.example",
"sub": "https://api.bank-a.example",
"jwks": { "keys": [ … ] },
"authority_hints": ["https://federation.example"],
"metadata": { "oauth_resource": {
"resource": "https://api.bank-a.example" } }
}
"metadata": { "oauth_resource": {
"authzen_policy_decision_points":
["https://pdp.bank-a.example/tenants/bank-a"],
"scopes_supported": [ … ],
"acr_values_required": ["urn:idp:loa:mfa"]
} },
"metadata_policy": { … }
{
"resource": "https://api.bank-a.example",
"authzen_policy_decision_points": [ … ],
"scopes_supported": [ … ],
"acr_values_required": ["urn:idp:loa:mfa"],
"signed_metadata": "eyJ…"
}
Nothing about the API's policy is configured on the gateway. The gateway walks its own chain and serves what the federation resolved as the API's RFC 9728 document, signed with the same key, so a plain RFC 9728 consumer reads the federation's word without knowing a federation is behind it. Until the controller has onboarded the entity, the document is self-asserted and says only which PDP the gateway is configured with.
authzen_policy_layers, and a federation can add its PDP to every member's. An estate-wide PDP that judges the token and the client goes first; every layer must permit; the first that does not is the answer. A layer may be marked fail-open, for the advisory PDP that is not worth an outage, and a permit that skipped one says so in a header.Everything with a specification behind it lives once, in Go. The Kong plugin does the two plain-JSON discoveries natively in Lua and delegates what needs a JOSE library; the PingAccess rule does the same in Java, and reads the identity PingAccess itself validated; the Node SDK does the same in TypeScript.
| Surface | Sits in front of | Finds the PDP | Federation | Federation face |
|---|---|---|---|---|
coaz-pep (Go) | Envoy, Istio, agentgateway, and the MCP routes of Kong and PingAccess | RFC 9728 and AuthZEN metadata | resolves trust chains to configured anchors | holds the key, republishes the resolved document |
| Kong plugin (Lua) | Kong routes | RFC 9728 and AuthZEN metadata, in Lua | delegated to coaz-pep | relayed from coaz-pep |
| PingAccess rule (Java) | PingAccess applications and resources | RFC 9728 and AuthZEN metadata, in Java | delegated to coaz-pep | relayed from coaz-pep |
| Node SDK (TypeScript) | Express, and MCP servers in process | RFC 9728 and AuthZEN metadata | no resolver; a seam for one | signs and serves its own, self-asserted |
Every surface forwards the same context, orders layers the same way, applies the same allowlists, and fails closed on the same refusals. A client sees one behaviour whichever enforcement point said no.
The demo is a stub federation, two banks' PDPs, an estate PDP and a rogue one, and the Go enforcement point three times in three discovery modes. For one request, each gateway shows the chain it followed, top to bottom, with the documents it read.
Open the demomember. The resource column believes the API's own word and permits. The federation column resolves the anchor's policy, hands the PDP an MFA floor the API never mentioned, and the same PDP denies.the gateway's own API, press the onboarding lever, discover again. The gateway's RFC 9728 document flips from self-asserted to the controller's word. Nothing on the gateway changed.Or run it yourself: cd demo && docker compose up --build -d && ./demo.sh. The scripted walk and the console are described in demo/README.md; the reasoning is in docs/architecture.md.
/.well-known/authzen-configuration./.well-known/oauth-protected-resource, including scopes_supported and signed_metadata. The PDP parameter is this project's own, provisional pending a working-group profile.oauth_resource is the entity type an API resolves as.