github.com/ID-Partners/idp-auth-peps  ·  Apache-2.0  ·  September 2026
idp-auth-peps

Nobody tells the gateway where the PDP is

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.

discovery 1

The gateway finds the PDP

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.

discovery 2

The PDP finds what to enforce

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.

What stops being configured

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 knowTodayWith discovery
Which API it is protectingconfiguredconfigured — the API's identifier is the one thing it is told
Which PDP decides for that APIconfigured (AUTHZEN_URL)read from the API's metadata, or from the federation
Where the PDP's endpoints areassumed (/access/v1/evaluation)read from the PDP's metadata
Which scopes and acr each route requiresconfigured on the gateway, compared by the gatewaypublished by the API, enforced by the PDP
Who is allowed to say any of the abovewhoever edits the gatewaythe federation's policy, when the API is a member
discovery 1

The gateway finds the PDP

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.

gateway the API the PDP GET /.well-known/oauth-protected-resource authzen_policy_decision_points: [pdp] GET /.well-known/authzen-configuration access_evaluation_endpoint POST evaluation { context: resource_metadata, request, access_token } 12345
The chain, in order. Orange is what was discovered: first the PDP's identifier, then its endpoint. Step 5 is where the second discovery happens.
what the API publishes · RFC 9728
{
  "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"]
}
what the PDP publishes · AuthZEN §9
{
  "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.

discovery 2

The PDP finds what to enforce

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.

what the gateway hands the PDP · AuthZEN context
"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…"
}
what the PDP answers
{
  "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.

Who gets to say who decides

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.

SELF-ASSERTED FEDERATION the API gateway the API the anchor gateway its own document pdps: [rogue, bank-a] acr: [password] signed by nobody. TLS says which host sent it; nothing says it may say it. the gateway takes the API's word the rogue decides onboarded entity configuration, signed by the API: pdps: [rogue, bank-a] acr: [password] the anchor's statement about it: pdps subset_of [bank-a] acr value [mfa] resolved: what survived the policy pdps: [rogue, bank-a] acr: [password mfa]
Same API, same claim about itself. On the left the gateway believes it. On the right the anchor's policy strips the rogue PDP and raises the acr floor before the gateway ever sees a PDP name, and the chain is signed all the way back to keys the operator installed by hand.

The gateway is the API's federation face

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.

the gateway publishes · minimal
{
  "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" } }
}
the controller maintains
"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": { … }
the gateway republishes · RFC 9728
{
  "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.

What it buys you

Four surfaces, one set of rules

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.

SurfaceSits in front ofFinds the PDPFederationFederation face
coaz-pep (Go)Envoy, Istio, agentgateway, and the MCP routes of Kong and PingAccessRFC 9728 and AuthZEN metadataresolves trust chains to configured anchorsholds the key, republishes the resolved document
Kong plugin (Lua)Kong routesRFC 9728 and AuthZEN metadata, in Luadelegated to coaz-peprelayed from coaz-pep
PingAccess rule (Java)PingAccess applications and resourcesRFC 9728 and AuthZEN metadata, in Javadelegated to coaz-peprelayed from coaz-pep
Node SDK (TypeScript)Express, and MCP servers in processRFC 9728 and AuthZEN metadatano resolver; a seam for onesigns 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.

See it

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 demo
  1. Press Discover. Read the three columns top to bottom: the document each gateway read, the PDP it landed on, and what it handed the PDP. The static gateway read nothing.
  2. Pick member. 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.
  3. Pick 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.

Standards in play