Skip to main content

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:8080 with a policy that allows refund actions up to a set amount.
  • A route already secured with the JWT middleware, so token.sub is available to the AuthZEN mapping.
  • Traefik Hub API Gateway installed, with access to apply Middleware resources.

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"}'
Two things that can go wrong when reading the request body

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 sub is agent-1 and the exchanged token's sub is agent-2, $token.sub resolves to agent-2. The PDP decides about agent-2, the identity the upstream service sees, so write PDP policies for that identity.
  • Not a JWT: $token has no claims, so $token.sub has no value and the request fails with a 500. 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:

  1. Add usernameClaim and forwardHeaders to the jwt-auth middleware. Use the claims that name the user and the agent in your tokens. This example assumes sub names the user and azp names the agent:

    jwt-auth middleware
    spec:
    plugin:
    jwt:
    usernameClaim: sub
    forwardHeaders:
    X-Evidence-User: sub
    X-Evidence-Agent: azp
  2. Keep those headers in the access log, which drops request headers by default:

    values.yaml
    accessLog:
    fields:
    headers:
    defaultMode: drop
    names:
    X-Evidence-User: keep
    X-Evidence-Agent: keep
  3. Enable tracing, so both records carry a trace_id:

    values.yaml
    tracing:
    otlp:
    enabled: true
  4. Set log.level to INFO or lower, so Traefik Hub writes the decision record:

    values.yaml
    log:
    level: INFO
  5. Add reason to the authzen-refund middleware, 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 middleware
    spec:
    plugin:
    authzen:
    reason: "payments API, refund"
warning

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​

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 pdpEndpoint from inside the cluster.
  • clientConfig.timeoutSeconds and clientConfig.maxRetries aren'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 middlewares list, so token carries no claims at all.
  • The caller's token doesn't carry the claim the expression names, for example a token issued without a sub claim.
  • The CEL expression's field path doesn't match the actual claim or field name, for example $token.sub instead of $token.subject.
  • A token exchange middleware runs before the AuthZEN middleware and returns a token that isn't a JWT, so token carries 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.