The Kong sideband plugin
sideband-pdp is a Kong plugin that enforces through PingAuthorize's Sideband API — the request Kong will forward to /sideband/request in the access phase, the upstream's response to /sideband/response in the response phase — and finds the PDPs it calls from the resource it protects: the resource's RFC 9728 metadata, or, with the switch, a federation resolve endpoint. It is the Kong plugin's discovery, layers and failure rules from the modules the two share, in front of a PDP protocol where the policy owns the request mapping and the shape of a deny. Every knob is plugin configuration on a route or service. Version 0.4.0; Kong Gateway 3.4 or later, 3.9 or later recommended.
Install
DB-less: mount both plugin directories and enable this one. The discovery and contract modules are authzen-pdp's, required by name (kong.plugins.authzen-pdp.discovery, …contract), so that directory must be on the package path whether or not authzen-pdp is enabled. The rock, kong-plugin-sideband-pdp-0.4.0-1.all.rock on the release, depends on kong-plugin-authzen-pdp 0.4.0 for them: install that one first.
# kong.conf, or environment
KONG_PLUGINS=bundled,sideband-pdp
KONG_LUA_PACKAGE_PATH=/opt/?.lua;;
KONG_LUA_SSL_TRUSTED_CERTIFICATE=system,/etc/kong/pingauthorize.pem # PingAuthorize's self-signed certificate
# compose
kong:
image: kong:3.9
environment:
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /kong/kong.yml
KONG_PLUGINS: bundled,sideband-pdp
KONG_LUA_PACKAGE_PATH: /opt/?.lua;;
KONG_PROXY_LISTEN: 0.0.0.0:8000
KONG_ADMIN_LISTEN: "off"
volumes:
- ./kong/kong.yml:/kong/kong.yml:ro
- ../gateways/kong/authzen-pdp:/opt/kong/plugins/authzen-pdp:ro # discovery.lua, contract.lua
- ../gateways/kong/sideband-pdp:/opt/kong/plugins/sideband-pdp:ro
ports:
- "8000:8000"
The plugin in Kong Manager
Kong Manager draws the form from schema.lua exactly as it does for authzen-pdp — every field below, defaults pre-filled, the required ones marked: service_url, shared_secret and secret_header_name. The Kong page shows what that looks like; this plugin has not been captured separately.
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/paz-secret} instead of a literal, and enc one Kong encrypts at rest where it has a keyring. The first group is Ping's ping-auth plugin's configuration, names and defaults unchanged, so a route configured for that plugin ports over by changing the plugin name.
PingAuthorize and its secret
| Field | Type · default | Meaning |
|---|---|---|
service_url required · ref | string | PingAuthorize's base URL, without /sideband… in the path: /sideband/request and /sideband/response are appended. The static PDP. With discovery on, the PDP for a resource that publishes nothing, and the one PDP that is called with shared_secret. Its origin is always permitted, even over http. Must be an http or https URL with a host, or a vault reference. |
shared_secret required · ref · enc | string | The Sideband API shared secret PingAuthorize expects from this PEP. Sent to service_url only; a discovered PDP is called with what pdp_credentials names for it, or with nothing. |
secret_header_name required | string | The header the secret travels in, as configured on the PingAuthorize Sideband API shared secret (commonly CLIENT-TOKEN). Also the default header for pdp_credentials entries. |
connection_timeout_ms | integer · 10000 | Timeout on each sideband call. |
connection_keepAlive_ms | integer · 60000 | Keep-alive on the connection pool to each PDP. |
verify_service_certificate | boolean · true | TLS verification on every sideband, metadata and resolver call. A PEP that accepts any certificate has no integrity on the decision it enforces. Off needs allow_insecure; trust PingAuthorize's self-signed certificate with lua_ssl_trusted_certificate instead. |
enable_debug_logging | boolean · false | Log each sideband call and its outcome at debug level. |
The route
| Field | Type · default | Meaning |
|---|---|---|
pep_label | string · kong-sideband-pep | Names this PEP in its own denials and the X-PDP-PEP response header. |
resource | string | The protected resource's identifier (RFC 8707), the key discovery starts from. A route without one uses service_url. |
forward_client_certificate | boolean · true | Send the client's TLS certificate as client_certificate: its public key as a JWK, the certificate in x5c — what PingAuthorize policy reads for a certificate-bound client. A certificate that cannot be parsed is a 400, as in ping-auth. |
filter_response | boolean · true | Make the /sideband/response calls, so policy can filter and rewrite the upstream's response. Off, the response phase only marks the response. Kong buffers the whole upstream response on any route this plugin is on, on or off — see compatibility. |
max_request_body_size | integer · 1048576 | The largest request body, in bytes, sent to the policy provider. A body Kong buffered to disk is read back up to this (Kong 3.9+); a larger one is a 413, never sent without its body. |
max_response_body_size | integer · 1048576 | The largest upstream response body, in bytes, sent to the policy provider. A larger one is withheld with a 502 rather than filtered in part or passed unfiltered. |
PDP discovery
| Field | Type · default | Meaning |
|---|---|---|
pdp_discovery | off | resource | federation-resolver · off | off: service_url, no fetch. resource: read the route's resource's RFC 9728 document for authzen_policy_decision_points (the PDP that decides for it; the first this route may call wins) and authzen_policy_layers (the PDPs to ask in front of it), falling back to service_url when the resource publishes nothing. federation-resolver: read the same two parameters from the resource's resolved oauth_resource metadata, from a federation resolve endpoint, and never from the resource's own document. No PDP metadata is probed in any mode: a sideband endpoint has none, and an identifier is the base its endpoints hang off. |
pdp_metadata_ttl | number · 300 | Cache TTL, seconds, for resource and resolved metadata. A fetch that fails, or an answer that does not validate, serves the last good answer for up to one more TTL, and never past a resolved answer's own exp; after that, or with none, the layer is unavailable and its rule decides. Must be greater than zero. |
pdp_allowlist | array of string | Prefixes a discovered or configured PDP must match; service_url is always permitted. A published layer passes this too. Required with discovery on. |
resource_metadata_allowlist | array of string | Prefixes a route's resource must match before its metadata is looked up, in either mode. Empty means any. An entry may carry a path. |
pdp_discovery_insecure | boolean · false | Allow http for discovered URLs and the resolve endpoint. Dev only, and needs allow_insecure; service_url's own origin is always trusted over http. |
The resolver switch
| Field | Type · default | Meaning |
|---|---|---|
federation_resolve_url | string | A federation resolve endpoint (OpenID Federation 1.0 §8.3), usually the trust anchor's. Required in federation-resolver mode, and https unless allow_insecure. Called as GET {url}?sub={resource}&anchor={federation_trust_anchor} with Accept: application/resolve-response+jwt. Configuration, not something discovered, so no allowlist bounds it; the URL policy still does. |
federation_trust_anchor | string | The trust anchor's entity identifier the resolver resolves under. Required in federation-resolver mode. |
What the switch is, and is not: the resolver's word, taken on transport. The resolve response is a JWT this plugin cannot verify — no JOSE verifier is available to a Kong plugin — so it is decoded, not verified. The plugin checks what it can: the response's typ is resolve-response+jwt, its sub is the resource asked about, it has not expired, and what it lists are PDP identifiers. The trust is TLS to a URL the operator configured, exactly the trust placed in service_url. That is a weaker claim than coaz-pep's federation mode, which walks the chain and verifies every signature to an anchor key it holds — which is why the mode, X-PDP-Source and context.resource_metadata_source all say federation-resolver, never federation. A route that needs the stronger claim belongs behind coaz-pep.
| The resolver answers | Discovery does |
|---|---|
not_found, or a 404 | No metadata: service_url decides, as for a resource outside the federation. |
invalid_trust_chain, invalid_metadata, invalid_subject, invalid_trust_anchor, invalid_request, unsupported_parameter, any other 4xx | A refusal: the request fails with a 503 whatever the failure rule says, and a cached answer is dropped, so revocation at the anchor reaches a running gateway. A resource that claims membership and fails validation is a signal, not an outage. |
server_error, temporarily_unavailable, a 5xx, unreachable | Transient: the cached answer while there is one and it has not passed its exp, then the layer's rule. |
a response that is not a JWS, has the wrong typ, has expired, or lists something that is not a PDP identifier | An invalid document: logged, not used; service_url decides. |
Layers and their rules
| Field | Type · default | Meaning |
|---|---|---|
pdp_layers | array of string · ["resource"] | The ordered PDPs to ask, every one of which must permit; the first that does not is the answer. Each entry is <static | resource | PDP identifier> [fail-open | fail-closed] [request-only]. static is service_url regardless of discovery; resource is what discovery finds, behind whatever the document publishes in authzen_policy_layers; an identifier is a PDP. A published layer takes the resource entry's rule and passes pdp_allowlist; a configured entry naming the same PDP is one call, and its own rule wins. request-only skips the layer's /sideband/response call. An entry the plugin cannot read fails the route closed. |
fail_mode | closed | open · closed | What a layer does when its PDP is unavailable — no answer, a 5xx, a 429 — unless the entry says for itself. closed denies with a 503 (a 429 with the PDP's Retry-After when that is what it said; the next request is asked afresh). 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 deny is a decision, never skipped; a refusal — a 3xx or 4xx (401 for a missing secret included), an answer it cannot read or apply, a payload it could not encode, the allowlist, an invalid chain — never opens. |
Credentials
| Field | Type · default | Meaning |
|---|---|---|
pdp_credentials | array of record | What a discovered or configured PDP is called with, by identifier: pdp (required), shared_secret (required · ref · enc), secret_header_name (defaults to the top-level one). shared_secret is bound to service_url and goes nowhere else. A PDP with no entry is called with nothing; PingAuthorize then answers 401, which is a refusal — closed whatever the layer's rule — so a resource that names a PDP this gateway holds no secret for is a resource this gateway cannot serve, and says so. |
Safety and compatibility
| Field | Type · default | Meaning |
|---|---|---|
allow_insecure | boolean · false | The one way past the configuration refusals below: discovery without pdp_allowlist, pdp_discovery_insecure, verify_service_certificate: false, a plain-http resolver. For development and demos. Kong logs what it relaxes, per route, when the configuration loads. |
protocols | http, https, grpc, grpcs | Kong's own field: which protocols the plugin runs on. |
Refused at configuration time, each with a message saying which: pdp_discovery: federation-resolver without federation_resolve_url or without federation_trust_anchor; a service_url that is neither an http or https URL with a host nor a vault reference; and, unless allow_insecure, discovery without pdp_allowlist, pdp_discovery_insecure: true, verify_service_certificate: false, and an http federation_resolve_url. The old mode name, federation, is refused.
REST routes only. A plugin with a response phase makes Kong buffer the whole upstream response on every route it is on, filter_response on or off. That is incompatible with SSE, so an MCP route belongs behind authzen-pdp and coaz-pep. Kong also refuses to load a plugin that has both a response phase and a header filter, which is why the X-PDP-* headers are set in access and on the plugin's exits rather than in a later phase. Accept-Encoding is dropped from the proxied request so the body the response phase reads is one the policy provider can read.
Responses Kong would not show it. Kong runs the response phase only under buffered proxying, which it turns off for a websocket upgrade on every release and for HTTP/2 before 3.9. Where any layer filters responses, such a request is refused with a 400 before any PDP is asked; a route that filters nothing lets it through, marked. On Kong 3.9 and 3.15 an HTTP/2 request on a filtering route is served and filtered.
Plugin order. Priority 999, after Kong's authentication plugins and before ip-restriction (990), acl (950) and rate-limiting (910). See the Kong page for what that costs and the Enterprise ordering that changes it.
What PingAuthorize is sent
- The URL Kong routed on: its scheme, the
Host, its own port and the normalised path its router matched, escaped for the wire, with the query normalised as Ping did. NeverX-Forwarded-*, which Kong honours from any client intrusted_ips— one there could haveDELETE /admin/users/1judged as/public/status. - The body, whole, up to
max_request_body_size, or a 413. - Every header and query argument, up to 1,000 of each, or a 431 or 414.
- No client
X-Auth-*: those are removed from the payload and from the request to the upstream; a layer's rewrite may add them.
A permit must have the permit's shape — method and url strings, headers a list of single-pair objects, body a string when present. Anything that is neither that nor a relayable response, a 2xx {} included, is a refusal.
Complete configurations
A REST API with discovery, a published layer and per-PDP credentials
_format_version: "3.0"
services:
- name: bank-api
url: http://bank-api:8070
routes:
- name: bank
paths: ["/bank"]
strip_path: true
plugins:
- name: sideband-pdp
config:
service_url: "{vault://env/paz-url}" # the static PDP; the one PDP called with shared_secret
shared_secret: "{vault://env/paz-secret}"
secret_header_name: CLIENT-TOKEN
pep_label: "PEP#2 (Bank API edge)"
pdp_discovery: resource
resource: https://api.bank.example
pdp_allowlist: ["https://pdp.bank.example", "https://pdp.estate.example"]
resource_metadata_allowlist: ["https://api.bank.example"]
pdp_credentials:
- pdp: https://pdp.bank.example/tenants/bank
shared_secret: "{vault://env/bank-pdp-secret}"
- pdp: https://pdp.estate.example
shared_secret: "{vault://env/estate-pdp-secret}"
secret_header_name: X-Estate-Token
pdp_layers: ["resource"] # what the document publishes, then its own PDP
fail_mode: closed
A route that takes the federation resolver's word
- name: sideband-pdp
config:
service_url: "{vault://env/paz-url}"
shared_secret: "{vault://env/paz-secret}"
secret_header_name: CLIENT-TOKEN
pdp_discovery: federation-resolver
resource: https://member.bank-federation.example
federation_resolve_url: https://anchor.bank-federation.example/resolve
federation_trust_anchor: https://anchor.bank-federation.example
pdp_allowlist: ["https://pdp.bank-a.example", "https://pdp.estate.example"]
pdp_credentials:
- pdp: https://pdp.estate.example
shared_secret: "{vault://env/estate-pdp-secret}"
An estate gate the route can live without
pdp_layers: ["static fail-open request-only", "resource"] # service_url first, as a gate; then the resource's stack
fail_mode: closed
What it puts on the wire
| Where | What | Meaning |
|---|---|---|
| to each PDP, access phase | POST {pdp}/sideband/request | The request Kong will forward: source_ip, source_port, method, url, http_version, headers (one pair per value), body, client_certificate; the PDP's secret in its header. Each layer sees the request as the previous one rewrote it. Back comes the request to forward, with a state, or under response the HTTP to send instead. |
| proxied request, on permit | the rewrites | Headers added, changed and removed; a new method, path, query or body — as the last layer left them. Accept-Encoding dropped; a client's X-Auth-* removed. |
| to each permitting PDP, response phase | POST {pdp}/sideband/response | The upstream's response_code, response_status, headers and body, with that layer's state (or the request it saw, when it returned none), in reverse order, skipping request-only layers. Back comes the response to send; a header the policy provider left out is removed. |
| response | X-PDP-PEP, X-PDP-Decision, X-PDP-Layers, X-PDP-Layer, X-PDP-Source, X-PDP-Fail-Open | Which PEP; PERMIT or DENY; the PDPs that permitted, in order; on a deny, whose answer it was; where the resource's PDP came from (static, rfc9728, federation-resolver); the identifiers of the layers skipped. On every permit, whether or not the response phase runs. Printable ASCII only. |
| response, on a policy deny | the policy's HTTP | Status, headers and body as PingAuthorize returned them — the WWW-Authenticate a stepped-up agent needs is the policy's to write — with each header value made header-safe. A 413 from the sideband API is relayed the same way. |
| response, on the plugin's own deny | 503, 429, 400, 413, 414, 431 | A PDP that could not be resolved, was unavailable under a closed rule, or refused (503); a PDP rate-limiting this gateway (429 with Retry-After); a client certificate that could not be parsed, or an Upgrade or pre-3.9 HTTP/2 request on a filtering route (400); a body, headers or query too large to send whole (413, 431, 414). Body {"error":"authorization_failed","pep":…,"reason":…}, the shape every PEP here uses, with a generic reason. |
| response, after the upstream ran | 502 | A response-phase layer unavailable under a closed rule, or refusing, or a response larger than max_response_body_size: every upstream header is cleared and the body says the upstream processed the request and its response was withheld. |
What it does not do
- Shape a challenge. A deny is the policy's HTTP. If the policy answers a flat 403, the client gets a flat 403. The challenge vocabulary the other PEPs render in code is, on this route, a contract the policy honours.
- Verify a token, a DPoP proof, or a chain. No JOSE. The token reaches PingAuthorize in the
authorizationheader, where policy can validate it; DPoP and COAZ are whatauthzen-pdpdelegates to coaz-pep, and an MCP route belongs there. - Read
signed_metadatain an RFC 9728 document; coaz-pep does. - Serve the resource's federation face.
authzen-pdp'sfederation_entity_urlrelays the two well-known documents from coaz-pep; put that plugin on the route for them.
The plugin is tested with busted against a mocked Kong (gateways/kong/spec/sideband-pdp_spec.lua, with the discovery rules in discovery_spec.lua) under the same coverage floor as authzen-pdp, and was run through Kong 3.7, 3.9 and 3.15 DB-less with both plugin directories mounted, against a stub that plays the sideband endpoints and an echoing upstream: the forwarded-path, HTTP/2, Upgrade and withheld-response behaviour above is what those runs showed.
Upgrading from 0.1
pdp_discovery: federationis nowfederation-resolver, andX-PDP-Sourcesays so; the old name is refused.- Discovery needs
pdp_allowlist;pdp_discovery_insecure,verify_service_certificate: falseand an http resolver needallow_insecure. - A 401 or 403 from PingAuthorize is a refusal, closed even on a fail-open layer; a 429 no longer blocks the PDP for the worker.
- The URL sent is the one Kong routed on, not
X-Forwarded-*. - Oversized bodies, too many headers or query arguments, and
Upgrade(or, before Kong 3.9, HTTP/2) on a filtering route are refused rather than judged in part or not filtered at all. - A response-phase failure is a 502 with the upstream's headers withheld.
- The rock is
kong-plugin-sideband-pdp-0.4.0-1, depending onkong-plugin-authzen-pdp0.4.0.