IDPartnersidp-auth-peps · reference
gateways/kong/ · Lua

The Kong plugin

authzen-pdp is a Kong plugin that authorises every request against an AuthZEN PDP. With coaz_url set, coaz-pep decides the whole request — it verifies the access token, X-User-Token and the DPoP proof, maps the request, finds the PDPs and asks them — and the plugin enforces the answer; every MCP route works this way, after the plugin has parsed its JSON-RPC strictly. Without it, a REST route is decided in Lua, on claims an auth plugin in front has verified: request mapping, RFC 9470 step-up challenges, and RFC 9728 and AuthZEN metadata discovery. Every knob is plugin configuration on a route or service, and Kong Manager shows it. Version 0.4.0; Kong Gateway 3.4 or later, 3.9 or later recommended.

Install

DB-less: mount the plugin directory and tell Kong about it. The module path Kong looks for is kong.plugins.authzen-pdp.handler. Or install the rock the v0.4.0 release carries: luarocks install kong-plugin-authzen-pdp-0.4.0-1.all.rock.

# kong.conf, or environment
KONG_PLUGINS=bundled,authzen-pdp
KONG_LUA_PACKAGE_PATH=/opt/?.lua;;
# compose: the demo's Kong, complete
kong:
  image: kong:3.9
  environment:
    KONG_DATABASE: "off"
    KONG_DECLARATIVE_CONFIG: /kong/kong.yml
    KONG_PLUGINS: bundled,authzen-pdp
    KONG_LUA_PACKAGE_PATH: /opt/?.lua;;
    KONG_PROXY_LISTEN: 0.0.0.0:8000
    KONG_ADMIN_LISTEN: "off"
    KONG_LOG_LEVEL: notice
    KONG_PROXY_ERROR_LOG: /dev/stderr
  volumes:
    - ./kong/kong.yml:/kong/kong.yml:ro
    - ../gateways/kong/authzen-pdp:/opt/kong/plugins/authzen-pdp:ro
  ports:
    - "8000:8000"

Before Kong 3.9, kong.request.get_raw_body cannot read back a body Kong buffered to disk, so a request body larger than client_body_buffer_size (8 KiB by default) is refused with a 413 rather than authorised unread; raise nginx_http_client_body_buffer_size on those releases. A PDP with a private or self-signed certificate is trusted through KONG_LUA_SSL_TRUSTED_CERTIFICATE=system,/path/to/ca.pem, not by turning verification off.

How a route is decided

With coaz_urlWithout it
Who decidescoaz-pep, the whole request, over POST /v1/mcp/checkthis plugin
The access tokenverified by coaz-pepread here, trusted because the route sets access_token_verified_upstream (an openid-connect or jwt plugin validated it first) or allow_insecure
X-User-Token, DPoPverified by coaz-pepnever read; require_user_login and require_dpop need coaz_url
RoutesREST and MCPREST only
PDP discoverycoaz-pep's, federation includedthis plugin's: off, authzen or resource

On an MCP route every request goes to coaz-pep — every JSON-RPC method, the SSE GET, the session DELETE — once its body has been read whole and parsed as exactly one JSON-RPC object. What the plugin refuses before asking is in the wire table.

The plugin in Kong Manager

Kong Manager draws the plugin's form from schema.lua, so every field below appears here with its default pre-filled, and the required ones marked. Captured from the demo's Kong with Kong Manager OSS turned on (KONG_ADMIN_GUI_LISTEN), against the 0.3.0 schema: 0.4.0 adds access_token_verified_upstream, allow_insecure and max_request_body_size to the same form, and a configuration the schema refuses is refused there with the messages below.

Kong Manager's edit form for the authzen-pdp plugin, every configuration field visible
Edit Plugin: authzen-pdp. The scope (a service, a route, a consumer, or global), then every configuration field. A red dot marks a required field: authzen_url and authzen_api_key. Kong Manager title-cases the names; the tables below use the names as written in configuration.
Kong Manager's read view of the plugin with its plugin-specific configuration
The saved plugin. The same configuration as Kong holds it, with Format switchable between structured and raw JSON, and the service it is scoped to.

Every field

Grouped as they are used, not as the schema lists them. Types are the schema's; ref marks a field that accepts a Kong vault reference such as {vault://env/authzen-url} instead of a literal, and enc one Kong encrypts at rest where it has a keyring.

The PDP

FieldType · defaultMeaning
authzen_url required · refstringThe static PDP's base URL. With discovery off the AuthZEN default paths are appended; with it, the PDP for a resource that publishes nothing, and the one PDP that receives the key. Its origin is always permitted, even over http. Plays no part when coaz_url is set.
authzen_api_key required · ref · encstringSent as Authorization: Bearer to authzen_url only. A discovered PDP never receives it.
pdp_ssl_verifyboolean · trueTLS verification on PDP and coaz-pep calls. A PEP that accepts any certificate has no integrity on the decision it enforces. Off needs allow_insecure; trust a private certificate with lua_ssl_trusted_certificate instead.

The route

FieldType · defaultMeaning
pep_labelstring · kong-pepNames this PEP in denials and the X-PDP-PEP response header, e.g. PEP#2 (Bank API edge).
stylerest | mcp · restrest maps method and path to an evaluation; mcp treats the route as an MCP edge, every request of which coaz-pep decides. mcp needs coaz_url.
require_tokenboolean · trueReject a request that carries no readable access token.
access_token_verified_upstreamboolean · falseWithout coaz_url, the plugin reads the access token's claims without verifying its signature. Set true when an openid-connect or jwt plugin on the route validates Authorization first — both run before this one. Give that plugin no anonymous consumer, or a request it could not authenticate reaches this one unverified. Without this, coaz_url or allow_insecure, the configuration is refused.
require_dpopboolean · falseEnforce the RFC 9449 sender constraint. coaz-pep checks the proof, so this needs coaz_url.
require_user_loginboolean · falseRequire a logged-in end user (X-User-Token), verified by coaz-pep; absent, a 401 login_required challenge. Needs coaz_url.
stepup_scopestringThe scope a step-up challenge names when the policy's advice names none.
stepup_actionstring · make_paymentThe action the step-up applies to; sent to coaz-pep.
forward_access_tokenboolean · falseForward the raw token to the PDP as context.access_token, so the PDP can verify the signature, read cnf, score the client. Only when the PDP connection is TLS and authenticated.
resourcestringThe protected resource's identifier (RFC 8707), the key discovery starts from. A rest route without one uses the static PDP. Sent to coaz-pep when set.
max_request_body_sizeinteger · 1048576The largest request body, in bytes, the plugin reads to authorise a request. A body Kong buffered to disk is read back up to this (Kong 3.9+); a larger one is a 413, never authorised unread.

Delegation to coaz-pep

FieldType · defaultMeaning
coaz_urlstringcoaz-pep's HTTP check API, e.g. https://coaz-pep.internal:9192. Set, coaz-pep decides the whole request on the route and the plugin enforces its answer. Required for style: mcp, require_dpop and require_user_login.
coaz_api_key ref · encstringThe engine's CHECK_API_TOKEN, sent as a bearer. Required with coaz_url.
mcp_upstream_urlstringThe MCP server whose tools/list declares the mappings; the engine reaches it directly for discovery, so it must be on the engine's MCP_UPSTREAM_ALLOWLIST.
coaz_defaultsboolean · trueGovern every MCP method by the COAZ-MCP binding's default mappings: tools that declare no mapping, and every method that is not a tools/call (ping, notifications and responses pass; anything unknown is denied). Only false keeps the old pass-through for methods other than tools/call, which is not conformant.
federation_entity_urlstringMakes the route the resource's federation face: /.well-known/openid-federation and /.well-known/oauth-protected-resource (with any identifier path) are relayed from coaz-pep at this URL, which holds the key. Everything else on the route is untouched.

PDP discovery

On a route decided in Lua. With coaz_url set, coaz-pep discovers under its own settings and these fields play no part.

FieldType · defaultMeaning
pdp_discoveryoff | authzen | resource · offoff: the static PDP, default paths, no fetch. 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, falling back to authzen_url when the resource publishes nothing. No federation mode: a trust chain cannot be validated without a JOSE verifier; a route that must take the federation's word belongs behind coaz-pep.
pdp_metadata_ttlnumber · 300Cache TTL, seconds, for resource and PDP metadata. A fetch that fails, or a document that does not validate, serves the last good document for up to one more TTL; after that, or with none, the layer is unavailable and its rule decides, and the failure is retried after 30 seconds. A 404 means no metadata: the static PDP, or the default paths.
pdp_allowlistarray of stringPrefixes a discovered PDP, and every endpoint its metadata advertises, must match; authzen_url is always permitted. Required with discovery on. An entry may carry a path (a tenant's PDP); the PDP's own well-known document is still fetched on its origin.
resource_metadata_allowlistarray of stringPrefixes a route's resource must match before its metadata is fetched. Empty means any. An entry may carry a path.
pdp_discovery_insecureboolean · falseAllow http for discovered URLs. Dev only, and needs allow_insecure; authzen_url's own origin is always trusted over http.

Layers and fail mode

FieldType · defaultMeaning
pdp_layersarray of string · ["resource"]The ordered PDPs to ask, every one of which must permit: static (the configured authzen_url, regardless of discovery), resource (what discovery finds for this route's resource), or a PDP identifier. Each entry may carry its own failure mode: "https://estate.example fail-open". The first that does not permit is the answer. An entry the plugin cannot read fails the route closed. Sent to coaz-pep with coaz_url.
fail_modeclosed | open · closedWhat a layer does when its PDP is unavailable — no answer, a 5xx, 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 one carries X-PDP-Fail-Open. A refusal — a 3xx or 4xx, an answer with no boolean decision, a request that could not be encoded, an allowlist miss — never opens, and a deny is a decision, not a failure. Always sent to coaz-pep with coaz_url.

Safety and compatibility

FieldType · defaultMeaning
allow_insecureboolean · falseThe one way past the refusals below that are about missing security: unverified claims, no coaz_api_key, no pdp_allowlist, plain-http discovery, TLS verification off. For development and demos. Kong logs what it relaxes, per route, when the configuration loads.
protocolshttp, https, grpc, grpcsKong's own field: which protocols the plugin runs on.
legacy_subject_identityboolean · trueAlso send the non-standard subject.identity beside AuthZEN's subject.id, so upgrading the gateway alone cannot break a policy still reading the old field. Set false once policies read subject.id.
user_token_subjectprincipal | pdp · principalWhose X-User-Token counts, applied by coaz-pep. principal counts only the access token's own subject's login. pdp also counts someone else's verified login — a staff member approving for a customer — and the PDP, which receives user_sub, decides whether they may. Needs coaz_url.

What the schema refuses

A configuration the plugin must not run with fails at load time, in Kong Manager, the Admin API and a DB-less file alike. allow_insecure relaxes the last five; the first three are things the plugin cannot do at all.

RefusedMessage
style: mcp without coaz_urlstyle=mcp needs coaz_url: every request on an MCP route is decided by coaz-pep
require_dpop without coaz_urlrequire_dpop needs coaz_url: this plugin cannot verify a DPoP proof signature itself, so verification is delegated to coaz-pep
require_user_login without coaz_urlrequire_user_login needs coaz_url: this plugin cannot verify X-User-Token's signature, so the route is decided by coaz-pep
no coaz_url and no access_token_verified_upstreamwithout coaz_url this plugin reads the access token's claims unverified: set access_token_verified_upstream when an openid-connect or jwt plugin validates Authorization first, or set coaz_url so coaz-pep verifies it
coaz_url without coaz_api_keycoaz_url needs coaz_api_key: coaz-pep's check API authenticates its callers with CHECK_API_TOKEN
discovery on without pdp_allowlistpdp_discovery needs pdp_allowlist: without one, any https PDP a resource names would be asked
pdp_discovery_insecure: truepdp_discovery_insecure needs allow_insecure: it lets a discovered URL be plain http
pdp_ssl_verify: falsepdp_ssl_verify=false needs allow_insecure: a PEP that accepts any certificate has no integrity on its decisions

Complete configurations

A REST API decided in Lua, behind Kong's jwt plugin, with discovery and layers

_format_version: "3.0"
services:
  - name: bank-api
    url: http://bank-api:8070
    routes:
      - name: bank
        paths: ["/bank"]
        strip_path: true
    plugins:
      - name: jwt                                              # priority 1450: runs first
        config:
          claims_to_verify: ["exp"]
      - name: authzen-pdp
        config:
          authzen_url:     "{vault://env/authzen-url}"        # the static PDP, always permitted
          authzen_api_key: "{vault://env/authzen-api-key}"
          pep_label: "PEP#2 (Bank API edge)"
          style: rest
          require_token: true
          access_token_verified_upstream: true               # the jwt plugin validated Authorization
          stepup_scope: "banking:payments:transfer"
          pdp_discovery: resource
          resource: https://api.bank.example
          pdp_allowlist: ["https://pdp.bank.example", "https://estate-pdp.bank.example"]
          resource_metadata_allowlist: ["https://api.bank.example"]
          pdp_metadata_ttl: 300
          forward_access_token: true
          pdp_layers: ["https://estate-pdp.bank.example fail-open", "resource"]
          fail_mode: closed
          federation_entity_url: http://coaz-pep:9192       # the two well-known documents, relayed
          legacy_subject_identity: false

A REST API coaz-pep decides, with DPoP and a logged-in user

      - name: authzen-pdp
        config:
          authzen_url:     "{vault://env/authzen-url}"
          authzen_api_key: "{vault://env/authzen-api-key}"
          pep_label: "PEP#2 (Bank API edge)"
          style: rest
          require_token: true
          require_dpop: true                                  # coaz-pep checks the proof
          require_user_login: true                            # coaz-pep verifies X-User-Token
          stepup_scope: "banking:payments:transfer"
          coaz_url: https://coaz-pep.internal:9192
          coaz_api_key: "{vault://env/coaz-api-key}"
          resource: https://api.bank.example                  # coaz-pep keys discovery off it
          pdp_layers: ["https://estate-pdp.bank.example fail-open", "resource"]

An MCP edge

  - name: bank-mcp
    url: http://bank-mcp:8090
    routes:
      - name: mcp-edge
        paths: ["/mcp"]
        strip_path: false
    plugins:
      - name: authzen-pdp
        config:
          authzen_url:     "{vault://env/authzen-url}"
          authzen_api_key: "{vault://env/authzen-api-key}"
          pep_label: "PEP#1 (MCP edge)"
          style: mcp
          require_token: true
          require_user_login: true
          coaz_url: https://coaz-pep.internal:9192            # every request of the route is decided there
          coaz_api_key: "{vault://env/coaz-api-key}"
          mcp_upstream_url: http://bank-mcp:8090/mcp
          max_request_body_size: 1048576

The demo's Kong, as it runs

The whole of demo/kong/kong.yml: one route in front of the plain stub, discovering the PDP from that resource's RFC 9728 document. It decides natively on the demo's unsigned tokens and discovers over plain http, so it sets allow_insecure, and Kong logs as much at startup.

_format_version: "3.0"

services:
  - name: plain-resource
    url: http://stubs:9004
    routes:
      - name: bank
        paths: ["/bank"]
        strip_path: true
    plugins:
      - name: authzen-pdp
        config:
          authzen_url: http://stubs:9002
          authzen_api_key: static-pdp-key
          pep_label: "Kong (resource discovery)"
          style: rest
          require_token: true
          pdp_discovery: resource
          resource: http://stubs:9004
          pdp_discovery_insecure: true
          resource_metadata_allowlist: ["http://stubs:9004"]
          pdp_allowlist: ["http://stubs:9002", "http://stubs:9008"]
          forward_access_token: true
          allow_insecure: true

Plugin order

Priority 1000. Kong runs a higher number first, so its authentication plugins — jwt 1450, oauth2 1400, key-auth 1250, openid-connect 1050 — run before this one, which is what access_token_verified_upstream relies on. ip-restriction 990, request-size-limiting 951, acl 950, rate-limiting 910 and request-transformer 801 run after: a request they would refuse still costs a PDP call, and a burst reaches the PDP before any limit is counted. On Kong Enterprise, dynamic ordering puts a plugin in front — ordering: { before: { access: ["authzen-pdp"] } } on the rate-limiting plugin.

What it puts on the wire

WhereWhatMeaning
proxied request, on permitX-Auth-Principal, X-Auth-Agent, X-Auth-Scope, X-Auth-AcrThe decided identity and the authentication context the AS asserted, so a resource server records the delegation chain rather than inferring a channel from a username. A client's own copies are removed before anything else; a claim the token lacks stays absent. With coaz_url, whatever coaz-pep returns, an empty value removing the header.
to coaz-pepPOST {coaz_url}/v1/mcp/checkThe route's knobs, the method, the normalised path, the authorization, x-user-token, dpop, content-type and content-encoding headers (each at most once, or a 400), and the body, read whole.
responseX-PDP-PEP, X-PDP-Decision, X-PDP-Action, X-PDP-ReasonWhich PEP decided, what, for which action, and why. Printable ASCII only: a line break in a policy's reason becomes a space.
response, on a fail-open permitX-PDP-Fail-OpenThe identifiers of the layers that were skipped. Why is in Kong's log.
response, on denystatus, WWW-Authenticate, JSON bodyThe same challenge vocabulary as every other PEP here: 401 login_required, insufficient_scope (RFC 9470), invalid_token; 403 for a plain deny; 503 when a PDP was unavailable under a closed rule, refused, or could not be resolved. Body {"error":"authorization_failed","pep":…,"reason":…}, or coaz-pep's rendering, relayed verbatim. WWW-Authenticate parameters are escaped as quoted-strings.
response, a request refused before asking413, 415, 431, 400On an MCP route, a JSON-RPC error: 413 -32600 for a body larger than max_request_body_size or one Kong cannot read back; 415 -32600 for a Content-Encoding other than identity; 400 -32700 for a BOM, invalid UTF-8, trailing data or anything not JSON; 400 -32600 for a batch, anything but one object, member names that differ only in case, a non-string method, a tools/call without a name, or a body on anything but a POST. On a REST route, 413 for an unreadable payment or account body and 400 for one that is not a JSON object or carries no finite amount. 431 for more than 1,000 headers.
to the PDPcontext.resource_metadata, context.resource_metadata_source, context.request, context.access_tokenThe resource's document verbatim and where it came from, the endpoint hit, and the raw token when the route allows. The plugin enforces none of it.

What is delegated, and why

The plugin is tested with busted against a mocked Kong that models the PDK behaviours these bugs hid in (gateways/kong/spec/), holds a coverage floor, and was run through Kong 3.7, 3.9 and 3.15 DB-less against a stub that plays the PDP and coaz-pep.

Upgrading from 0.3