IDPartnersidp-auth-peps · reference
core/ · Go

coaz-pep

The engine every other surface leans on. One binary serves Envoy's external-authorization gRPC and an HTTP check API over the same enforcement core, and it is the only component that resolves OpenID Federation trust chains, verifies DPoP proofs and evaluates COAZ tool-call mappings. Service-wide settings are environment variables; per-route settings arrive with each request.

There is no screen for this component. It is configured by environment variables and by the gateway that calls it. This page lists every one, then shows what they produce: the startup log, the documents the PEP serves, and the demo console's trace of a request under a given configuration.

What it is

What it enforces: token presence and validation, the RFC 9449 sender constraint, RFC 9470 step-up and login challenges, REST and MCP request mapping to AuthZEN, a decision on every MCP message (a tool's declared mapping, otherwise the binding's default one), PDP discovery in four modes, ordered policy layers with per-layer failure modes, and forwarding the resource's own requirements to the PDP without judging them. A body it cannot read the way the upstream will — a batch, a truncated or encoded body, case-folded duplicate members — is refused, never waved through.

Run it

go build ./... && go test -race ./...

docker build --build-arg VERSION=0.4.0 -t coaz-pep core/
docker run -p 9191:9191 -p 9192:9192 \
  -e AUTHZEN_URL=https://pdp.bank.example -e AUTHZEN_API_KEY=… \
  -e CHECK_API_TOKEN=… -e MCP_UPSTREAM_ALLOWLIST=https://mcp.bank.example/mcp \
  -e ACCESS_TOKEN_JWKS_URL=https://as.bank.example/jwks \
  -e ACCESS_TOKEN_ISSUER=https://as.bank.example -e ACCESS_TOKEN_AUDIENCE=https://api.bank.example \
  coaz-pep

It will not start without its security settings — the check API's token and upstream allowlist, the JWKS, issuer and audience to verify tokens against, and the discovery allowlists when discovery is on — and names every missing one in the error. PEP_ALLOW_INSECURE=true starts it anyway, logging each gap as a warning; that is for development and the demo, never for anything a real client reaches. Released images are ghcr.io/id-partners/coaz-pep: distroless, nonroot, signed with cosign, with an SBOM and build provenance.

A production-shaped service, as a compose service. Every variable is explained in the reference below; the values here are a bank API fronted by a gateway, discovering its PDP from the federation, holding the key for its own federation face.

services:
  coaz-pep:
    image: ghcr.io/id-partners/coaz-pep:0.4.0 # pin a release — better, its digest
    environment:
      # the static PDP: for a resource that publishes nothing; the only PDP that gets the key
      AUTHZEN_URL: https://pdp.bank.example
      AUTHZEN_API_KEY: ${AUTHZEN_API_KEY}
      # the HTTP check API is reachable by Kong / PingAccess: authenticate it, bound what it may fetch
      CHECK_API_TOKEN: ${CHECK_API_TOKEN}
      MCP_UPSTREAM_ALLOWLIST: https://mcp.bank.example/mcp
      # validate tokens before their claims are used
      ACCESS_TOKEN_JWKS_URL: https://as.bank.example/jwks
      ACCESS_TOKEN_ISSUER: https://as.bank.example
      ACCESS_TOKEN_AUDIENCE: https://api.bank.example
      USER_TOKEN_AUDIENCE: https://app.bank.example
      # discovery: the federation's word, bounded
      PDP_DISCOVERY: federation
      PDP_METADATA_TTL: 5m
      RESOURCE_METADATA_ALLOWLIST: https://api.bank.example,https://mcp.bank.example
      PDP_ALLOWLIST: https://pdp.bank.example,https://estate-pdp.bank.example
      FEDERATION_TRUST_ANCHORS_FILE: /etc/coaz-pep/anchors.json
      FEDERATION_FETCH_ALLOWLIST: https://federation.example
      FEDERATION_MAX_PATH_LENGTH: "2"
      # an estate PDP first, advisory; then whoever the resource's metadata names
      PDP_LAYERS: https://estate-pdp.bank.example fail-open, resource
      PDP_FAIL_MODE: closed
      # this PEP is the federation face of the API it fronts
      FEDERATION_ENTITY_ID: https://api.bank.example
      FEDERATION_ENTITY_KEY_FILE: /etc/coaz-pep/entity-key.json
      FEDERATION_AUTHORITY_HINTS: https://federation.example
      # the origin clients make DPoP proofs for
      DPOP_HTU_BASE: https://api.bank.example
    volumes:
      - ./anchors.json:/etc/coaz-pep/anchors.json:ro
      - ./entity-key.json:/etc/coaz-pep/entity-key.json:ro
    ports: ["9191:9191", "9192:9192"]

Environment reference

Every variable the binary reads. A blank default means the setting is off or unset. A required mark means startup refuses without it, because the safe value is one the operator has to choose; PEP_ALLOW_INSECURE=true starts anyway and logs the gap. A value that does not parse — a duration, a mode — refuses startup too, rather than falling back to a default.

Core

VariableDefaultMeaning
AUTHZEN_URL required—The static PDP's base URL, e.g. https://pdp.bank.example. With discovery off the AuthZEN default paths are appended; with discovery on it decides for a resource that publishes nothing, and it is the one PDP that receives AUTHZEN_API_KEY. A trailing slash is trimmed.
AUTHZEN_API_KEY—Sent as Authorization: Bearer to AUTHZEN_URL only. A discovered PDP never receives it.
PORT9191The ext_authz gRPC port. Dual-stack.
HTTP_PORT9192The HTTP check API port, also where the well-known documents are served.
HTTP_ADDRall interfacesBind address for the HTTP port, e.g. 127.0.0.1 when only a co-located gateway should reach it.
COAZ_DISCOVERY_TTL60sHow long a discovered tools/list is reused for COAZ mappings. A Go duration.
PEP_ALLOW_INSECUREfalsetrue starts the PEP despite missing security settings, logging each one, and marks every audit record "insecure":true. Development and the demo only.
PDP_TLS_INSECUREfalsetrue skips TLS verification on PDP calls — not on metadata or federation fetches. Needs PEP_ALLOW_INSECURE.
GRPC_TLS_CERT_FILE, GRPC_TLS_KEY_FILEplaintextServe ext_authz over TLS. Without them keep :9191 reachable only by the gateways — a mesh sidecar's mTLS, or a NetworkPolicy — since a caller there chooses the route's knobs.
GRPC_TLS_CLIENT_CA_FILE—Require and verify client certificates on ext_authz (mTLS).
DPOP_HTU_BASE—The external origin clients make DPoP proofs for, e.g. https://api.bank.example. A proof's htu must be that origin plus the request path. Unset, only the path is compared, exactly.
LOG_FORMATjsonjson or text. Either way there is one decision record per check.

Check API security

The gRPC port takes its per-route configuration from the gateway's own config. The HTTP check API is different: the caller supplies config.mcp_upstream_url and the authorization header, and the PEP fetches that URL with that header. Unbounded, that is a server-side request forgery and credential-relay primitive, so both guards are required. A check request is capped at just over 1 MiB.

VariableDefaultMeaning
CHECK_API_TOKENrequiredShared secret callers must present as a bearer on /v1/mcp/check and /v1/dpop/verify. Kong sets it as coaz_api_key, PingAccess as coaz_api_key, the Node SDK as delegate.apiKey.
MCP_UPSTREAM_ALLOWLISTrequiredComma-separated prefixes a caller-supplied mcp_upstream_url may match. Matched on scheme, host and path prefix against the parsed URL, so https://mcp.example.com does not admit https://mcp.example.com.evil.test.

Token validation

The COAZ-MCP binding requires the access token to be validated — signature, issuer, audience, expiry — before its claims are used, so all three settings are required. A token that fails verification is a 401 invalid_token. An X-User-Token counts only when it verifies, carries USER_TOKEN_AUDIENCE, has the access token's sub, and is not delegated (no act) or the access token itself; otherwise it yields no claims, so the step-up and consent gates it feeds close rather than open.

VariableDefaultMeaning
ACCESS_TOKEN_JWKS_URLrequiredJWKS to verify the access token's signature (ES256/384/512, RS/PS256/384/512). alg: none and unknown kid are refused. Refreshed in the background every ten minutes with one shared fetch; stale keys keep verifying through up to an hour of failed refreshes, then stop. An unknown kid fetches at most every 30 seconds.
ACCESS_TOKEN_ISSUERrequired with a JWKSExpected iss.
ACCESS_TOKEN_AUDIENCErequired with a JWKSExpected aud, as a string or a member of an array.
USER_TOKEN_JWKS_URLthe access-token JWKSJWKS for X-User-Token, whose claims drive user_scope, user_acr, authorization_details and the consented-amount cap.
USER_TOKEN_ISSUERthe access-token issuerExpected iss for X-User-Token.
USER_TOKEN_AUDIENCE—Expected aud for X-User-Token. Unset, X-User-Token is ignored and require_user_login routes deny: the agent's own token comes from the same issuer and verifies against the same keys.

PDP discovery

VariableDefaultMeaning
PDP_DISCOVERYoffoff: the static PDP, default paths, no HTTP. authzen: read AUTHZEN_URL's /.well-known/authzen-configuration. resource: read the route's resource's RFC 9728 document for authzen_policy_decision_points, then that PDP's metadata; fall back to static only when the resource publishes nothing (a 404, or no PDP named). federation: resolve the resource's trust chain to a configured anchor and read the resolved oauth_resource metadata; never consult the resource's own document; fall back to static only for a resource that is not a member.
PDP_METADATA_TTL5mCache TTL for resource and PDP metadata, and for resolved chains. A short value also shortens how long a failed chain is remembered.
PDP_ALLOWLISTrequired in resource and federation modesComma-separated prefixes a discovered PDP must match. AUTHZEN_URL and any identifier named in PDP_LAYERS are always permitted.
RESOURCE_METADATA_ALLOWLISTrequired in resource and federation modesComma-separated prefixes a route's resource must match before its metadata or entity configuration is fetched. Governs the subject's own document in every mode.
PDP_DISCOVERY_INSECUREfalsetrue accepts http for discovered URLs and entity identifiers. Needs PEP_ALLOW_INSECURE; AUTHZEN_URL's own origin is always accepted.

Federation

VariableDefaultMeaning
FEDERATION_TRUST_ANCHORS_FILE—, required in federation modeA JSON file of the trust anchors this PEP recognises and their keys, distributed out of band. A chain is only valid if it ends at one of these, verified against these keys and never against the anchor's own published configuration. Also required for the federation face to republish resolved metadata.
FEDERATION_FETCH_ALLOWLISTrequired in federation modeComma-separated prefixes for the climb from a resource to its anchor: superiors' entity configurations and fetch endpoints. The subject's own is governed by RESOURCE_METADATA_ALLOWLIST.
FEDERATION_MAX_PATH_LENGTH4Intermediates allowed between a resource and its anchor.

The anchors file:

{
  "https://federation.example": {
    "keys": [
      { "kty": "EC", "crv": "P-256", "kid": "…", "x": "…", "y": "…" }
    ]
  }
}

Layers and fail mode

VariableDefaultMeaning
PDP_LAYERSresourceThe ordered PDPs every route asks unless it names its own. One entry per comma or line: static (the configured PDP, regardless of discovery), resource (whatever discovery resolves for the route's resource), or a PDP identifier. Each may be followed by fail-open or fail-closed. Every layer must permit; the first that does not is the answer; duplicates are one call; every layer gets the same context. An unreadable entry fails startup.
PDP_FAIL_MODEclosedWhat a layer does when its PDP is unavailable — a transport error, a timeout, a 5xx or a 429 — unless the layer says for itself. closed denies with a 503. open skips the layer; if every layer was skipped the request is permitted, and any permit that skipped a layer carries X-PDP-Fail-Open, naming the layers. A deny is never skipped, nor is a refusal: an allowlist miss, an invalid chain, or a PDP that answers a 3xx or 4xx or something that is not a decision. Startup warns when set to open.

The federation face

Set FEDERATION_ENTITY_ID and the PEP is the federation entity for the resource it fronts. It publishes a minimal entity configuration for a trust controller to onboard, and republishes what the federation resolved as the resource's RFC 9728 document. Nothing about the resource's policy is configured here; the controller maintains it.

VariableDefaultMeaning
FEDERATION_ENTITY_ID—The resource identifier this PEP fronts, an absolute URL without query or fragment. Enables {path}/.well-known/openid-federation and /.well-known/oauth-protected-resource{path} on the HTTP port.
FEDERATION_ENTITY_KEY_FILE—, required with an idA private JWK (EC P-256/384/521, or RSA with d, p, q) the entity signs with. Its public half, with an RFC 7638 thumbprint as kid, is the Federation Entity Key.
FEDERATION_ENTITY_KEY_GENERATEfalsetrue mints a P-256 key into the file (mode 0600) when the file is absent, and logs its kid. The controller must onboard that key before the chain resolves.
FEDERATION_AUTHORITY_HINTS—, required with an idComma-separated superiors the trust controller is reached through; published as authority_hints.

The key file, as generated:

{
  "crv": "P-256",
  "d": "…",
  "kid": "mbAWDqnKestjJF8pyLi8LMGfARPUeBAS7LDCiKulELc",
  "kty": "EC",
  "x": "…",
  "y": "…"
}

Before the controller has onboarded the entity, the RFC 9728 document is self-asserted and carries only resource, authzen_policy_decision_points: [AUTHZEN_URL] and signed_metadata; the response header X-Resource-Metadata-Source says self. Once the chain resolves it says federation and the document is the controller's word.

Per-route knobs

These are not environment variables. They arrive with each request: as ext_authz context_extensions from Envoy, Istio or agentgateway, or as the config object of an HTTP check from Kong, PingAccess or the Node SDK. Values are strings; booleans are "true" and "false", and anything else — "yes", "1" — fails the route closed rather than reading as off. So does a style that is neither rest nor mcp.

KeyDefaultMeaning
pep_labelcoaz-pepNames this PEP in denials, logs and the X-PDP-PEP response header.
stylerestrest maps method and path to an evaluation; mcp treats the route as an MCP edge and decides every JSON-RPC message.
require_tokenfalseDeny a request with no readable access token.
require_dpopfalseEnforce the RFC 9449 sender constraint on every request: the cnf.jkt binding, the proof's signature, htm, htu, ath, iat freshness and jti replay. Off, a token that is DPoP-bound, or presented as DPoP, is still checked — a bound token presented as a bearer token is rejected on every route (§7.2).
require_user_loginfalseDeny without a valid X-User-Token belonging to the access token's principal, with a 401 login_required challenge.
stepup_scope—The scope demanded for stepup_action; missing, a 401 insufficient_scope challenge.
stepup_actionmake_paymentThe action the step-up applies to.
mcp_upstream_url—The MCP server whose tools/list declares per-tool mappings, followed across pages. Must match MCP_UPSTREAM_ALLOWLIST. Without it every tools/call gets the binding's default mapping.
resource—The protected resource's identifier, RFC 8707, the key discovery starts from. An mcp route defaults to mcp_upstream_url; a rest route without one uses the static PDP.
forward_access_tokenfalseSend the raw token to the PDP as context.access_token. Only over a PDP connection that is TLS and authenticated.
pdp_layersPDP_LAYERSThis route's ordered layers, same grammar as the variable. A PDP identifier named here must be on PDP_ALLOWLIST. An unreadable value fails the route closed.
fail_modePDP_FAIL_MODEclosed or open for this route's layers that say nothing for themselves.
coaz_defaultstrueDecide every MCP method with the COAZ-MCP binding's default mappings where no declared mapping applies, and deny a method the binding does not know, as the binding requires. "false" brings back the old pass-through for everything but declared tools, and the coarse access_mcp check on the handshake — an explicit, non-conformant opt-out.
coaz_v2_onlyfalseRefuse tools that declare only the superseded coaz: true mapping, whose subject can come from the caller's params.
user_token_subjectprincipalWhose X-User-Token counts. principal: only the access token's own subject's login. pdp: someone else's verified login counts too — a staff member approving for a customer — and the PDP, which always receives user_sub and user_iss, decides whether they may.
legacy_subject_identitytrueAlso send the non-standard subject.identity beside AuthZEN's subject.id. Set "false" once policies read subject.id.

The HTTP API

POST /v1/mcp/check

The whole decision for one request, for gateways that cannot host the logic themselves — REST or MCP, tokens and DPoP verified here. Authenticated by CHECK_API_TOKEN. Callers forward authorization, x-user-token, dpop, content-type and content-encoding; on an MCP route they send every request here, not only tool calls.

POST /v1/mcp/check
Authorization: Bearer <CHECK_API_TOKEN>
Content-Type: application/json

{
  "config":  { "pep_label": "PEP#2 (Bank API edge)", "style": "rest", "require_token": "true",
               "resource": "https://api.bank.example", "forward_access_token": "true",
               "pdp_layers": "https://estate-pdp.bank.example fail-open, resource" },
  "method":  "POST",
  "path":    "/payments",
  "headers": { "authorization": "Bearer …", "x-user-token": "…", "content-type": "application/json" },
  "body":    "{\"from_account\":\"a1\",\"to_account\":\"b2\",\"amount\":50,\"currency\":\"AUD\"}"
}
// permit
{ "decision": true,
  "upstream_headers": { "X-Auth-Principal": "customer", "X-Auth-Agent": "agent-1", "X-Auth-Scope": "accounts:read payments:write",
                        "X-Auth-Acr": "" },  // empty: remove the client's copy
  "response_headers": { "X-PDP-PEP": "PEP#2 (Bank API edge)", "X-PDP-Decision": "PERMIT",
                        "X-PDP-Action": "make_payment", "X-PDP-Reason": "Permitted by policy." } }

// deny: status, headers and body are returned to the client verbatim
{ "decision": false,
  "response": { "status": 401,
                "headers": { "WWW-Authenticate": "Bearer error=\"insufficient_scope\", scope=\"payments:approve\"" },
                "body": "{\"error\":\"authorization_failed\",\"pep\":\"PEP#2 (Bank API edge)\",\"reason\":\"…\"}" } }

POST /v1/dpop/verify

The sender-constraint check on its own, without a PDP evaluation as a side effect. Same bearer. The access token is validated first when a JWKS is configured, since a proof is only as good as the cnf.jkt it is compared with.

{ "method": "POST", "path": "/payments", "pep_label": "kong",
  "headers": { "authorization": "DPoP <token>", "dpop": "<proof>" } }

{ "valid": true }
{ "valid": false, "reason": "DPoP proof signature is invalid.", "status": 401 }

The well-known documents

With FEDERATION_ENTITY_ID=https://api.bank.example/v1 the PEP answers GET /v1/.well-known/openid-federation with application/entity-statement+jwt and GET /.well-known/oauth-protected-resource/v1 with JSON. Route those two paths on the resource's host to HTTP_PORT as an ordinary upstream, not through ext_authz; they are public.

// the entity configuration, decoded — minimal by design
{ "iss": "https://api.bank.example/v1", "sub": "https://api.bank.example/v1",
  "iat": 1788930600, "exp": 1789017000,
  "jwks": { "keys": [ { "kty": "EC", "crv": "P-256", "kid": "…", "x": "…", "y": "…" } ] },
  "authority_hints": ["https://federation.example"],
  "metadata": { "oauth_resource": { "resource": "https://api.bank.example/v1" } } }

// the RFC 9728 document once the controller has onboarded the entity
{ "resource": "https://api.bank.example/v1",
  "authzen_policy_decision_points": ["https://pdp.bank.example/tenants/bank-a"],
  "scopes_supported": ["accounts:read", "payments:write"],
  "acr_values_required": ["urn:idp:loa:mfa"],
  "signed_metadata": "eyJ…" }

What it puts on the wire

WhereHeader or fieldMeaning
upstream request, on permitX-Auth-Principal, X-Auth-Agent, X-Auth-Scope, X-Auth-AcrThe decided identity: the human, the acting client, the scope and how the token was authenticated. A resource server records the delegation chain from these rather than inferring it. The client's own copies never reach it: a value is set over them, and a header the PEP has no value for is removed.
response, on permitX-PDP-PEP, X-PDP-Decision, X-PDP-Action, X-PDP-ReasonWhich PEP decided, what it decided, for which action, and the PDP's reason.
response, on a fail-open permitX-PDP-Fail-OpenThe identifiers of the layers that were skipped — never the error, which is logged — so a permit resting on fewer opinions than the policy asked for is visible and countable.
response, on denystatus, WWW-Authenticate, body401 with invalid_token, login_required (insufficient_user_authentication), insufficient_scope (RFC 9470) or identity_verification_required, or DPoP error="invalid_dpop_proof"; 403 for a plain deny; 400 for a request the PEP will not read (a non-canonical path, an ambiguous body); 503 when a PDP was unavailable or refused. The body is {"error":"authorization_failed","pep":…,"reason":…}. On an MCP route it is a JSON-RPC error: HTTP 200 for a policy deny, 400 (-32700/-32600) for a body the PEP will not read, 413 for a truncated one, 415 for an encoded one, 405 for a method other than POST, GET or DELETE. Reasons are generic; the detail is in the log.
to the PDPcontext.resource_metadata, context.resource_metadata_source, context.request, context.access_tokenThe resource's document verbatim, where it came from (rfc9728 or federation), the endpoint hit, and the raw token when the route allows. Every layer receives the same context.

What the log tells you

Logs are JSON. Every relaxation PEP_ALLOW_INSECURE allowed is a warning at startup, and every check leaves one decision record — never a token. This is the demo's federation-mode PEP starting and deciding, abridged.

{"level":"INFO","msg":"coaz-pep 0.4.0"} {"level":"INFO","msg":"PDP discovery (federation): http://stubs:9002/tenants/bank-a evaluates at http://stubs:9002/tenants/bank-a/access/v1/evaluation"} {"level":"WARN","msg":"minted a new federation entity key into /tmp/entity-key.json (kid PFlZTqivykMP9Z1TomZCu8yuFcgTqih7Gc90fE2lZgo) — the trust controller must onboard this key before the chain resolves."} {"level":"WARN","msg":"insecure setting allowed by PEP_ALLOW_INSECURE","gap":"PDP_DISCOVERY_INSECURE is set: discovered http URLs are accepted"} {"level":"WARN","msg":"insecure setting allowed by PEP_ALLOW_INSECURE","gap":"ACCESS_TOKEN_JWKS_URL is unset: access tokens and X-User-Token are decoded, not verified"} {"level":"INFO","msg":"coaz-pep HTTP check API listening on :9192"} {"level":"INFO","msg":"coaz-pep ext_authz (Envoy gRPC) listening on [::]:9191, PDP at http://stubs:9002/tenants/bank-a"} {"level":"INFO","msg":"decision","pep":"demo","surface":"http","style":"rest","method":"POST","path":"/payments","outcome":"permit","status":200,"action":"make_payment","reason":"customer may make_payment (examined the token: client agent-1, acr urn:idp:loa:password)","fail_open":"","insecure":true,"duration_ms":1.443} {"level":"INFO","msg":"decision","pep":"demo","surface":"http","style":"rest","method":"POST","path":"/payments","outcome":"deny","status":403,"action":"make_payment","reason":"this resource requires acr [urn:idp:loa:mfa] (per its federation metadata); the token was authenticated at \"urn:idp:loa:password\"","fail_open":"","insecure":true,"duration_ms":1.2} {"level":"INFO","msg":"decision","pep":"demo","surface":"http","style":"rest","method":"GET","path":"/accounts/a1/balance","outcome":"deny","status":503,"action":"","reason":"Authorization service unavailable; denying (fail-closed).","fail_open":"","insecure":true,"duration_ms":1.639}

Without PEP_ALLOW_INSECURE the same configuration does not start: one ERROR record names every gap and the process exits. Also logged: PDP_FAIL_MODE=open and any fail-open layer; a route whose policy could not be read; every permit that failed open, with the layers it skipped and why; the detail behind every generic reason a client was given. SIGTERM logs the drain.

What the settings produce

The demo console runs one request through three instances of this binary, in three PDP_DISCOVERY modes, and shows the chain each followed. The third column is the federation-mode PEP configured as above.

The demo console: three coaz-pep instances deciding one request on the federated member resource
Three configurations of coaz-pep, one request. PDP_DISCOVERY=off reads nothing and permits on the token alone. resource reads the API's own document and believes it. federation resolves the anchor's policy, hands the PDP an MFA floor the API never mentioned, and the same PDP denies. Nothing but the environment differs between the columns.
The RFC 9728 document coaz-pep serves for the resource it fronts, in a browser
The federation face. The RFC 9728 document this PEP serves at /.well-known/oauth-protected-resource once the controller has onboarded it: the PDP, the scopes and the acr are the controller's, with signed_metadata.
A PDP's AuthZEN metadata document in a browser
What discovery reads next. The PDP's authzen-configuration, with the well-known segment inserted after the host and the identifier's path kept: /.well-known/authzen-configuration/tenants/bank-a.

Worked configurations

The demo runs the binary three ways. These are its compose services, complete; the allowlists list every stub because an allowlist entry matches scheme, host and port. The first block's settings are shared by all three.

# pep-static: told where its PDP is, no discovery
AUTHZEN_URL: http://stubs:9002/tenants/bank-a
AUTHZEN_API_KEY: static-pdp-key
CHECK_API_TOKEN: demo
PEP_ALLOW_INSECURE: "true"   # the demo's tokens are unsigned; never anywhere real
MCP_UPSTREAM_ALLOWLIST: http://stubs:9001,http://stubs:9004,http://stubs:9005,http://stubs:9006,http://stubs:9009
PDP_METADATA_TTL: 15s

# pep-resource: trusts each resource's own RFC 9728 document (deliberately no PDP_ALLOWLIST, so the impostor can name the rogue)
PDP_DISCOVERY: resource
PDP_DISCOVERY_INSECURE: "true"
RESOURCE_METADATA_ALLOWLIST: http://stubs:9001,http://stubs:9004,http://stubs:9005,http://stubs:9006,http://stubs:9007,http://stubs:9009,http://pep-federation:9192

# pep-federation: trusts the federation's word, and is the federation face of the API it fronts
PDP_DISCOVERY: federation
PDP_DISCOVERY_INSECURE: "true"
RESOURCE_METADATA_ALLOWLIST: http://stubs:9001,http://stubs:9004,http://stubs:9005,http://stubs:9006,http://stubs:9007,http://stubs:9009,http://pep-federation:9192
PDP_ALLOWLIST: http://stubs:9002,http://stubs:9008,http://stubs:9098
FEDERATION_TRUST_ANCHORS_FILE: /shared/anchors.json
FEDERATION_FETCH_ALLOWLIST: http://stubs:9000
FEDERATION_ENTITY_ID: http://pep-federation:9192
FEDERATION_ENTITY_KEY_FILE: /tmp/entity-key.json
FEDERATION_ENTITY_KEY_GENERATE: "true"
FEDERATION_AUTHORITY_HINTS: http://stubs:9000

The single-container image in demo/railway/ runs the same three from one shell script, demo/railway/entrypoint.sh, which is the shortest complete example of starting the binary in each mode.