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

The Kong plugin

authzen-pdp checks Kong requests against an AuthZEN PDP. Set coaz_url to delegate decisions to coaz-pep. This is required for MCP routes. REST routes can also run directly in Lua using claims verified by an authentication plugin. Configure the plugin on a route or service through Kong Manager or declarative configuration. Version 0.4.2 requires Kong Gateway 3.4 or later. Kong 3.9 or later supports reading request bodies buffered to disk.

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.2 release carries: luarocks install kong-plugin-authzen-pdp-0.4.2-1.all.rock. Kong's Enterprise images (kong/kong-gateway) ship without unzip, which luarocks needs to open a rock: apt-get install -y unzip first, as root.

# 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. A DPoP-bound token (cnf.jkt, or the DPoP scheme) is refused whatever require_dpop says: a 401 invalid_dpop_proof sent as Bearer or without a proof, a 503 with one, since the plugin cannot verify it (RFC 9449 §7.2)
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, max_request_body_size and user_token_subject to the same form, 0.4.2 the three timeouts and decision_deadline_ms, 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

Fields are grouped by function. Types match the schema. 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 the X-PDP-PEP response header and, on a challenge, in authz_challenge.pep, 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: a 401 invalid_token. With no token at all, WWW-Authenticate is Bearer with resource_metadata alone and no error.
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 on every request. coaz-pep checks the proof, so this needs coaz_url. Off, a DPoP-bound token is still checked: coaz-pep verifies it on a delegated route, and a native route refuses it.
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 (403 insufficient_scope) names when the PDP's context names none in scope or step_up_scope.
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.
coaz_timeout_msinteger · 15000Timeout on the call to coaz-pep's check API, or decision_deadline_ms when that is shorter.
pdp_timeout_msinteger · 10000Timeout on each PDP layer's evaluation, or what is left of the deadline when that is less. A PDP that times out is unavailable, and the layer's rule decides.
discovery_timeout_msinteger · 5000Timeout on each metadata document fetched, and on the metadata publisher relayed from coaz-pep. Never cut short by the deadline: what a fetch finds is cached for every request.
decision_deadline_msinteger · 20000What one decision may take, counted from when the request has been read: discovery and every layer. A layer with nothing left is unavailable, and its rule decides. A client that sends slowly spends its own time, not the decision's.

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 metadata publisher: /.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_set, then each 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; use coaz-pep for verified federation discovery.
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.
honour_published_fail_openboolean · falseAct on a fail_open a member carries in the resource's own RFC 9728 document. Self-asserted, so ignored by default. Under federation-resolver a published fail_open is honoured unless the resolved metadata carries authzen_fail_open_permitted: false.
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 route's own PDPs, asked in front of the resource's policy set and combined deny-overrides with it, so every one must permit: static (the configured authzen_url, regardless of discovery), resource (the resource's authzen_policy_set, or the deprecated pair read as one), or a PDP identifier. On resource an entry's rule is the default every member of the published set inherits. 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 four 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
user_token_subject: pdp without coaz_urluser_token_subject=pdp needs coaz_url: X-User-Token is only read, and judged, 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 settings, 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 repository's refusal contract (authzen-pdp/refusal.lua, held to the shared vectors in core/authzen/refusal/testdata/challenges), so the same status, body and WWW-Authenticate as every other PEP here. A PDP names a refusal with an obligation flag in its context, honoured on a permit too - identity_proofing_required (401 identity_verification_required, with doctype), step_up_required (403 insufficient_scope, RFC 6750, with scope), authn_required (401 login_required, with acr_values) - or on a deny with context.error: 401 insufficient_user_authentication (RFC 9470, with acr_values and max_age), 401 invalid_token, 403 risk_too_high and the rest of the contract's list; anything else is 403 access_denied. The plugin's own: 401 invalid_token, 400 invalid_request, 413 request_too_large, and 503 temporarily_unavailable when a PDP was unavailable under a closed rule, refused, or could not be resolved, or the route is misconfigured. Body {"status","error","error_description"}, plus scope, acr_values, max_age or doctype where they apply, and authz_challenge (type, the parameters, reason, pep) for a challenge; with coaz_url, coaz-pep's rendering, relayed verbatim. resource_metadata, on invalid_token and insufficient_scope, is the RFC 9728 URL of the route's own resource, never a PDP's. WWW-Authenticate parameters are escaped as quoted-strings. A step-up used to be a 401, and the plugin's own denials authorization_failed; neither is sent any more.
response, a request refused before asking413, 415, 400On any route, a repeated authorization, x-user-token, dpop, content-type or content-encoding header is a 400 invalid_request and more than 1,000 headers a 413 request_too_large, in the refusal contract's body. On an MCP route, the rest is 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, a body Kong cannot read in full is a 413 request_too_large; a payment or account body that is not a JSON object, or a payment without a from_account and an amount, or with any field a policy weighs in the wrong shape (an account identifier that is not 1-64 letters, digits, _, ., : or -; an amount that is not a JSON number greater than zero, numeric strings included; a currency that is not three upper-case letters; a non-string description or non-boolean internal_transfer), is a 400 invalid_request. A body that names a member twice in one object, in any case, or that carries a byte-order mark, is a 400 invalid_request, as is a raw path with a ; or \, an empty or dot segment, an escaped reserved character, %, . or \, or a control character once decoded. On any route, a request carrying X-HTTP-Method-Override, X-HTTP-Method or X-Method-Override is a 400 invalid_request. A read (list_accounts, get_balance) is only ever a GET or HEAD; any other method on its path is http:<method>, any POST with a payments segment is a payment, and the method is compared upper-cased.
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.

A step-up, from a PDP that permits with step_up_required, step_up_scope: "payments:approve" and the reason Payment over the threshold. (one of the shared vectors, on a route with the default pep_label and no resource):

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="payments:approve"
Content-Type: application/json

{"status":403,"error":"insufficient_scope","error_description":"Payment over the threshold.",
 "scope":"payments:approve",
 "authz_challenge":{"type":"resource_authorisation","scope":"payments:approve",
                    "reason":"Payment over the threshold.","pep":"kong-pep"}}

An RFC 9470 refusal, a deny with context.error: "insufficient_user_authentication", acr_values: "urn:bank:acr:strong" and max_age: 300, is a 401 with WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="urn:bank:acr:strong", max_age="300", and both parameters in the body and in authz_challenge (type authn). max_age is a whole number of seconds from 0 to 231-1, 0 included; anything else is left out.

What is delegated, and why

The plugin has busted tests against a mocked Kong in gateways/kong/spec/. The test suite enforces minimum coverage. Integration runs cover Kong 3.7, 3.9 and 3.15 in DB-less mode against stub PDP and coaz-pep services.

Upgrading from 0.3