Skip to main content

Configure AuthZEN Authorization for MCP Gateway

Early Access

This middleware is currently in early access, available starting v3.21.0-ea.3.

This guide sets up the AuthZEN MCP middleware so an AI agent's transfer_funds tool call only proceeds when an external policy engine approves it, while every other MCP method keeps working normally. Even an unattended agent can't move funds outside what that policy engine allows, since the decision doesn't live in the agent or in this gateway config.

See Understanding AuthZEN Authorization for the concept behind this middleware, and AuthZEN for MCP Gateway 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 transfer actions within a per-user limit.
  • A route already secured with the JWT middleware. token.sub becomes available to the AuthZEN mapping as soon as the JWT middleware validates the token, regardless of forwardAuthorization. Only set forwardAuthorization: true if the upstream MCP server itself needs the raw Authorization header. By default the access-log entry for a call identifies nobody: ClientUsername is - and no subject or agent field is present. Step 4 covers what to set if you need the evidence to name them.
  • Traefik Hub MCP Gateway enabled (hub.mcpgateway.enabled=true). This middleware doesn't require the base MCP middleware to also be attached; add it too if you also need OAuth resource discovery or its TBAC-style policies.

Without the MCP Gateway enabled, a route referencing an authzen-mcp middleware fails to build with unknown plugin type: authzen-mcp in the Traefik Hub logs, and typically answers with a 404. A route using only the API Gateway authzen middleware is unaffected. This looks the same as a typo in the middleware name, so check both if a route returns a 404.

Step 1: Confirm the PDP's AuthZEN endpoint

curl http://openfga:8080/.well-known/authzen-configuration/<store-id>

A successful response confirms pdpEndpoint in the next step is correct. Like the API Gateway middleware, this one fails closed whenever the PDP is unreachable, so nothing else works until this succeeds.

Step 2: Create the AuthZEN MCP middleware

Apply a Middleware resource with one policy for transfer_funds, leaving every other MCP method to the built-in default policies. A PDP may also constrain which characters a subject identifier can contain. OpenFGA forbids @, :, #, and blank characters in Subject.Id, so a sub claim that's an email address needs reshaping first. Whether yours is depends on the provider and the token: Okta puts the user's login in sub on access tokens, which is the kind the JWT middleware validates, while Keycloak's is a UUID.

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: authzen-mcp
namespace: apps
spec:
plugin:
authzen-mcp:
pdpEndpoint: "http://openfga:8080/.well-known/authzen-configuration/<store-id>"
defaultOnDenyResponse:
status: 403
body: '{"error":"forbidden"}'
policies:
- name: transfer-limit
match: Equals(`mcp.method`, `tools/call`) && Equals(`mcp.params.name`, `transfer_funds`)
mapping:
evaluation:
subject:
type: "user"
id: "$token.sub.replace('@', '_')"
action:
name: "transfer"
resource:
type: "account"
id: "$params.arguments.to_account"
onDenyResponse:
status: 403
body: '{"error":"forbidden"}'

The route now has a decision maker for transfer_funds; every other tool call still goes through the built-in default policies.

Step 3: Attach the middleware to a route

apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: finance-mcp
namespace: apps
spec:
routes:
- kind: Rule
match: Host(`api.example.com`)
middlewares:
- name: mcp-jwt-auth
- name: authzen-mcp
services:
- name: finance-mcp-server
port: 80

Order matters here too: the JWT middleware must run first so token.sub is available when the AuthZEN middleware builds its mapping.

Step 4: Record who acted and on whose authority

By default, the access-log entry for an MCP call doesn't identify who made it. Three settings put the principal and the delegation chain into the record, and all three are needed:

  1. Set the claim that identifies the end user, on the JWT middleware:

    usernameClaim: sub

    That populates ClientUsername, which reads - otherwise.

  2. Copy the claims you want into request headers. On the JWT middleware, for the user and the calling agent:

    forwardHeaders:
    X-Evidence-Sub: sub
    X-Evidence-Agent: azp

    And on the token exchange middleware, for the agent the call was delegated to:

    forwardHeaders:
    X-Evidence-Agent2: azp
  3. Tell the access log to keep those headers. Request headers are dropped by default:

    accessLog:
    fields:
    headers:
    defaultMode: drop
    names:
    X-Evidence-Sub: keep
    X-Evidence-Agent: keep
    X-Evidence-Agent2: keep

Each step depends on the one before it: the claim populates a header, and the header only reaches the access log if the third setting above names it. Skip that setting, and the headers still reach the upstream MCP server, but the access-log entry won't include them.

Step 5: Test the allow and deny paths

Call transfer_funds for an account and amount the PDP's policy allows:

curl -X POST https://api.example.com/mcp \
-H "Authorization: Bearer $ALLOWED_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"transfer_funds","arguments":{"to_account":"acct-123","amount":50}}}'

The call reaches the MCP server as usual. Now call it again for an account the PDP's policy rejects:

curl -X POST https://api.example.com/mcp \
-H "Authorization: Bearer $ALLOWED_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"transfer_funds","arguments":{"to_account":"acct-blocked","amount":50}}}'

Traefik Hub wraps the configured onDenyResponse in a JSON-RPC error, echoing the request's id:

{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32003,
"message": "Forbidden",
"data": {
"error": "forbidden"
}
}
}

onDenyResponse.body can reference .Decision, the context object the PDP returned with its decision, but only if your PDP returns one. OpenFGA's evaluation response carries no context, so .Decision.reason renders <no value>, in the response body and in the reason log template alike.

A call to a different tool, or to a standard method like tools/list, still works without any policy of your own. The built-in default policies already cover it.

Step 6: Read the decision Traefik Hub recorded

Both calls above left a record. Traefik Hub logs one line per authorization decision, at info, so a production log level keeps it:

{"level":"info","middlewareName":"authzen-mcp","middlewareType":"authzen-mcp","trace_id":"216f2cb6…","decision":"deny","policy":"transfer-limit","reason":"amount 50 over limit 20","transparency_log_id":"…","time":"2026-09-09T13:44:07Z","message":"Request denied"}

decision is one of three values, and deny covers three messages, so match on decision rather than on message:

decisionmessage
allow"Request allowed"
bypass"Bypassed authorization"
deny"Request denied"
deny"No policy matched the request"
deny"Token audience does not include the configured resource identifier"

policy is the name of the policy that fired, and is absent if that policy has no name. reason is the policy's reason template, rendered against the PDP's answer. The example above assumes a policy reason template and a PDP that returns context. This walkthrough's own policy sets neither, and OpenFGA doesn't return context either, so following it produces a decision line with no reason field.

The decision is a separate record from the access-log entry, tied to it by trace_id. To follow one request end to end, read the trace id off the decision line and look it up in the access log:

TRACE=$(grep '"decision"' traefik.log | tail -1 | jq -r .trace_id)
grep "$TRACE" access.log | jq '{TraceId, RequestPath, DownstreamStatus}'

With Transparency Logs enabled, the decision line also carries a transparency_log_id. Both the access-log entry and the decision line are committed to the transparency log, and you can verify either one.

Next steps

Troubleshooting

Calls 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 call. Check that the PDP is reachable at the configured pdpEndpoint, and that clientConfig.timeoutSeconds/clientConfig.maxRetries give a slow-but-healthy PDP enough time to respond. The configured onDenyResponse only applies on an explicit PDP deny, not on a PDP error. A subject or resource identifier built from a claim can also cause this. OpenFGA validates Subject.Id against ^[^:#@\s]{1,500}$ and Resource.Id against ^[^:#@\s]{1,256}$, so a sub claim that's an email address fails validation and Traefik Hub reports a 500 rather than a clean deny. Reshape the value, for example $token.sub.replace('@', '_').

A tool call is denied even though it should match a custom policy
  • Your own policies are checked in order, before the built-in ones. If a broader built-in policy for the same method is somehow matching first, check the match expression is specific enough (matching on both mcp.method and mcp.params.name).
  • Confirm the JWT middleware runs before the AuthZEN MCP middleware in the route's middlewares list, so token.sub isn't empty.
  • Field names in mapping are case-sensitive; $params.arguments.to_account won't resolve if the tool argument is actually named toAccount.
The middleware fails to start

A policy with strategy: bypass cannot also set mapping or onDenyResponse; a policy without strategy: bypass must set mapping. Check the Traefik Hub logs for the specific policy index and validation error.