Configure AuthZEN Authorization for API Gateway
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.
Without a token exchange middleware, the mapping reads $token.sub from the token the JWT middleware validated, and the upstream service receives that same token.
If a token exchange middleware runs between the JWT and AuthZEN middlewares, it replaces the Authorization header with an exchanged token.
The upstream service receives the exchanged token.
What the mapping reads depends on the exchanged token:
- A JWT: the mapping reads the claims of the exchanged token. For example, if the original token's
subisagent-1and the exchanged token'ssubisagent-2,$token.subresolves toagent-2. The PDP decides aboutagent-2, the identity the upstream service sees, so write PDP policies for that identity. - Not a JWT:
$tokenhas no claims, so$token.subhas no value and the request fails with a500. Use a token exchange that returns a JWT when the mapping reads$token.
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.
Step 5: Record the user and the agent
Traefik Hub writes a decision record for each authorization decision. It writes an access record for each request. By default, the access record doesn't identify the user or the agent that made the request, and the decision record names neither. Configure these settings so the access record names the user and the agent, and the decision record names the rule:
-
Add
usernameClaimandforwardHeadersto thejwt-authmiddleware. Use the claims that name the user and the agent in your tokens. This example assumessubnames the user andazpnames the agent:jwt-auth middlewarespec:plugin:jwt:usernameClaim: subforwardHeaders:X-Evidence-User: subX-Evidence-Agent: azp -
Keep those headers in the access log, which drops request headers by default:
values.yamlaccessLog:fields:headers:defaultMode: dropnames:X-Evidence-User: keepX-Evidence-Agent: keep -
Enable tracing, so both records carry a
trace_id:values.yamltracing:otlp:enabled: true -
Set
log.leveltoINFOor lower, so Traefik Hub writes the decision record:values.yamllog:level: INFO -
Add
reasonto theauthzen-refundmiddleware, so the decision record names the rule. Use a fixed string, because a PDP that returns no context, such as OpenFGA, gives a template nothing to render:authzen-refund middlewarespec:plugin:authzen:reason: "payments API, refund"
Set forwardHeaders on every route that uses these header names.
On a route that doesn't, the access record contains whatever value the client sent, so a client can forge the user and agent on that route.
If the route uses a token exchange middleware, set usernameClaim and forwardHeaders on the middleware whose token names the user and the agent.
See Configure identity and trace fields for how to choose.
Apply the middleware changes, then upgrade Traefik Hub with the new values.yaml.
Send the refused request from Step 4 again.
The decision record in the Traefik Hub log shows decision: deny and reason: payments API, refund.
The access record with the same trace_id shows the user, the agent, and the 403 status.
See Audit authorization decisions for both records in full and for the cases that leave no decision record.
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. Common 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. - A token exchange middleware runs before the AuthZEN middleware and returns a token that isn't a JWT, so
tokencarries no claims.
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.
