Configure AuthZEN Authorization for API Gateway
This middleware is currently in early access, available starting v3.21.0-ea.3.
This guide sets up the AuthZEN middleware on a route, using a payments API's refund endpoint as the example. Instead of a static rule baked into the gateway, an external policy engine decides whether a specific refund is allowed. The same approach works for any route where that decision should come from a policy engine rather than the gateway's own configuration, and a compliance team can change the policy without touching this configuration.
See Understanding AuthZEN Authorization for the concept behind this middleware, and the AuthZEN reference for every configuration field.
Prerequisites
- An AuthZEN-compliant PDP reachable from the cluster, for example OpenFGA or Keycloak.
This guide assumes a PDP is already running at
http://openfga:8080with a policy that allowsrefundactions up to a set amount. - A route already secured with the JWT middleware, so
token.subis available to the AuthZEN mapping. - Traefik Hub API Gateway installed, with access to apply
Middlewareresources.
Step 1: Confirm the PDP's AuthZEN endpoint
Fetch the PDP's AuthZEN configuration to confirm it's reachable before wiring up the middleware:
curl http://openfga:8080/.well-known/authzen-configuration/<store-id>
A successful response confirms pdpEndpoint in the next step is correct.
If this fails, none of the following steps will work, since the middleware fails closed whenever it can't reach the PDP.
Step 2: Create the AuthZEN middleware
Apply a Middleware resource that maps each request to a refund decision:
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: authzen-refund
namespace: apps
spec:
plugin:
authzen:
pdpEndpoint: "http://openfga:8080/.well-known/authzen-configuration/<store-id>"
mapping:
evaluation:
subject:
type: "user"
id: "$token.sub"
action:
name: "refund"
resource:
type: "order"
id: "$request.body.order_id"
context:
amount: "$request.body.amount"
onDenyResponse:
status: 403
body: '{"error":"forbidden"}'
A request with no body, or a body the middleware can't decode as JSON, resolves request.body to null.
Reading .order_id off null is a mapping error, so the middleware returns a plain 500 instead of the configured onDenyResponse.
Write $request.body.?order_id to get the configured deny instead of a 500 when the caller sends no body or an unexpected shape.
A request that doesn't declare its body length up front also fails, with a 400, whenever the mapping reads request.body.
This affects chunked transfer-encoding requests over HTTP/1.1, and any HTTP/2 request sent without a content-length header, which a streaming client commonly omits.
The route now has a decision maker; the next step connects it to actual traffic.
Step 3: Attach the middleware to a route
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: payments-api
namespace: apps
spec:
routes:
- kind: Rule
match: Host(`payments.example.com`) && PathPrefix(`/refunds`)
middlewares:
- name: jwt-auth
- name: authzen-refund
services:
- name: payments-service
port: 80
Order matters: the JWT middleware must run first so token.sub is available when the AuthZEN middleware builds its mapping.
A token-exchange middleware between JWT and AuthZEN changes which principal the decision is actually about.
Without one, the JWT middleware validates a token for agent-1, and AuthZEN's mapping reads $token.sub from that same token and asks the PDP about agent-1.
The upstream service also receives agent-1's token, so the decision and the acting principal match.
With a token-exchange middleware in between, the JWT middleware still sets the original token's claims in context.
The token-exchange middleware then swaps the Authorization header for an exchanged token, for example one scoped to agent-2, before the request reaches the upstream service.
AuthZEN's mapping still reads $token.sub from the pre-exchange claims, so it asks the PDP about agent-1, not agent-2.
A PDP that permits agent-1 but denies agent-2 lets the request through, and the upstream service executes it as agent-2.
A policy meant to check the identity that actually acts on the request needs to account for this. The same gap affects the MCP middleware's TBAC policies.
Step 4: Test the allow and deny paths
Send a refund request for a user and order the PDP's policy allows:
curl -X POST https://payments.example.com/refunds \
-H "Authorization: Bearer $ALLOWED_TOKEN" \
-H "Content-Type: application/json" \
-d '{"order_id":"order-123","amount":50}'
The request reaches payments-service as usual.
Now send the same request with an amount the PDP's policy rejects:
curl -X POST https://payments.example.com/refunds \
-H "Authorization: Bearer $ALLOWED_TOKEN" \
-H "Content-Type: application/json" \
-d '{"order_id":"order-123","amount":5000}'
Traefik Hub returns the configured onDenyResponse:
{"error":"forbidden"}
onDenyResponse.body is a Go template that can reference .Decision, the context object the PDP returned with its decision. OpenFGA's evaluation response carries no context, so there's no reason to surface here.
The middleware is now deciding refunds through the PDP instead of a static rule.
Next steps
- See the AuthZEN reference for the full
mapping.evaluations(multi-decision) shape and every configuration field. - Read Understanding AuthZEN Authorization for the PEP/PDP model this middleware implements.
Troubleshooting
Requests fail with 500 Internal Server Error
A 500 means Traefik Hub couldn't get a decision from the PDP at all, which is different from the PDP explicitly denying the request.
Check:
- The PDP is reachable at the configured
pdpEndpointfrom inside the cluster. clientConfig.timeoutSecondsandclientConfig.maxRetriesaren't set so low that a slow-but-healthy PDP can't respond in time.
The configured onDenyResponse only applies when the PDP returns an explicit deny, not when it's unreachable.
Requests fail with 500 and the log says "no such key"
A CEL expression like $token.sub errors instead of resolving to empty when the field it names is missing. This produces a 500, not an empty subject or resource. Three measured causes:
- No JWT middleware runs before the AuthZEN middleware in the route's
middlewareslist, sotokencarries no claims at all. - The caller's token doesn't carry the claim the expression names, for example a token issued without a
subclaim. - The CEL expression's field path doesn't match the actual claim or field name, for example
$token.subinstead of$token.subject.
Use the optional form, $token.?sub, to get a deny instead of a 500 when the field may legitimately be missing.
The middleware fails to start
Traefik Hub validates mapping when the middleware loads, not per request.
A missing subject.type, subject.id, action.name, resource.type, or resource.id in mapping.evaluation (or the same fields in every entry of mapping.evaluations) prevents the middleware from starting at all.
Check the Traefik Hub logs for the specific validation error.
