AuthZEN for MCP Gateway
This middleware is currently in early access, available starting v3.21.0-ea.3.
The AuthZEN MCP middleware sends MCP requests to an external Policy Decision Point (PDP) over the AuthZEN protocol, and enforces its decision.
It authorizes the POST leg of Streamable HTTP, which carries every tool, prompt and resource call.
The GET and DELETE legs, which open the server-to-client stream and end the session, are passed through without a decision, so the MCP server is responsible for binding streams and sessions to the principal that opened them.
See Understanding AuthZEN Authorization for the concept behind this middleware.
Use it when an MCP tool call, such as a fund transfer or a database write, needs a decision from an external policy engine instead of a static allow or deny rule.
The API Gateway AuthZEN middleware evaluates one mapping against every request. This middleware instead matches each MCP request against an ordered list of policies. Each policy targets one MCP method or tool. That matching works the same way as the MCP middleware's own policies.
Configuration Example
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: authzen-mcp
namespace: apps
spec:
plugin:
authzen-mcp:
pdpEndpoint: "http://openfga:8080/.well-known/authzen-configuration/01KYSHBDW2QW6R5SE9CRJRGGKY"
defaultOnDenyResponse:
status: 403
body: '{"error":"forbidden"}'
policies:
- match: Equals(`mcp.method`, `tools/call`) && Equals(`mcp.params.name`, `transfer_funds`)
mapping:
evaluation:
subject:
type: "user"
id: "$token.sub"
action:
name: "transfer"
resource:
type: "account"
id: "$params.arguments.to_account"
onDenyResponse:
status: 403
body: '{"error":"forbidden"}'
- match: Equals(`mcp.method`, `ping`)
strategy: bypass
A denied transfer_funds call returns its own onDenyResponse.
A denied call to any other tool falls back to defaultOnDenyResponse, since only the transfer_funds policy sets one.
| Situation | Response used |
|---|---|
A policy has its own onDenyResponse, and the PDP denies it | That policy's onDenyResponse |
A policy has no onDenyResponse, and the PDP denies it | defaultOnDenyResponse |
| No policy matches at all, custom or built-in | defaultOnDenyResponse |
Traefik Hub always wraps a deny as a JSON-RPC error, echoing the request's id so the MCP client can correlate it with the original call.
The configured body becomes the error's data field, parsed as JSON when it's valid JSON.
For the transfer_funds policy above, a denied call returns something like:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32003,
"message": "Forbidden",
"data": {
"error": "forbidden"
}
}
}
error.code is -32003 for a 403 status and -32603 for any other status.
error.message is the HTTP status text for the configured status.
onDenyResponse.body and defaultOnDenyResponse.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 a field like .Decision.reason renders <no value> in the response body and in the reason log template.
Configuration Options
| Field | Description | Default | Required |
|---|---|---|---|
pdpEndpoint | URL of the PDP's AuthZEN configuration endpoint. | "" | Yes |
clientConfig | HTTP client settings used to reach the PDP (timeout, retries, TLS). Shared shape with the API Gateway AuthZEN middleware's clientConfig. | No | |
resourceIdentifier | The MCP server's own identifier. When set, the token's aud must contain it, and it's used as the mcp_server resource identifier. When unset, the identifier is derived from aud, which fails if the token carries more than one audience. | "" | No |
defaultOnDenyResponse.status | HTTP status code returned when no policy matches the request. | 403 | No |
defaultOnDenyResponse.body | Go template for that response body. | "" | No |
defaultOnDenyResponse.headers | Extra headers to set on that response. Traefik Hub sets Content-Type: application/json on the JSON-RPC envelope after these, so a Content-Type set here is discarded. | No | |
defaultReason | Go template used as the log reason when the matching policy sets no reason of its own. | "" | No |
policies | Ordered list of policies matched against each MCP request. The first matching policy applies; if none match, defaultOnDenyResponse applies. More information in Configuring policies. | [] | No |
resourceIdentifier is optional.
Leaving it unset derives the server's identity from the token's aud claim directly, which fails if the token carries more than one audience.
Without it, nothing checks that a token was issued for this server.
The JWT middleware validates a token's signature and issuer, but not its audience.
A token issued for one MCP server's route can be replayed against a different MCP server's route, and AuthZEN evaluates it as if it belonged there.
Setting resourceIdentifier closes this. The token's aud claim must contain the value you set, or the request is rejected before any policy runs.
That rejection is a 403 carrying a fixed JSON-RPC error, and not your defaultOnDenyResponse, because a token issued for another server is a token error rather than a policy decision.
Request body size is governed by the global hub.mcpgateway.maxRequestBodySize Helm flag, shared by every MCP Gateway middleware, not a per-middleware setting.
See MCP Gateway for how to set it.
Configuring policies
Each policy has a match expression, using the same expression language as the MCP middleware, and either a mapping or strategy: bypass:
| Field | Description | Required |
|---|---|---|
name | Name for this policy. Traefik Hub logs it as the policy that fired. Omit it and that field is absent from the log, and Traefik Hub warns about it at startup. | No |
match | Expression that must evaluate to true for this policy to apply. | Yes |
mapping | The COAZ mapping used to build the AuthZEN request: either mapping.evaluation or mapping.evaluations, the same shared shape the API Gateway middleware uses. Required unless strategy is bypass. | Conditional |
reason | Go template rendered against the decision and written to the log line, not to the response. Same template data as onDenyResponse.body. | No |
strategy | Set to bypass to skip AuthZEN entirely for requests matching this policy. Cannot be combined with mapping or onDenyResponse. | No |
onDenyResponse | Overrides defaultOnDenyResponse for requests denied by this specific policy. Same shape as defaultOnDenyResponse. Cannot be combined with strategy: bypass. | No |
Applies to both gateways: the mapping shape itself, and the $ CEL convention.
MCP Gateway only: this middleware evaluates CEL expressions against token and params (the MCP call's parameters).
Its match expression is a separate, non-CEL policy-matching language that also sees mcp (method, id).
Referencing mcp inside a mapping fails to compile: the middleware never loads, and every route using it stops working.
All built-in defaults that carry a mapping follow the split correctly, matching on mcp.method in match and reading $params.name, $params.uri, or $params.taskId in mapping:
policies:
- match: Equals(`mcp.method`, `tools/call`) # match expression: roots are mcp and jwt
mapping:
evaluation:
resource:
id: "$params.name" # mapping CEL: roots are token and params
The API Gateway middleware uses token and request instead.
Built-in default policies
Traefik Hub already has a default policy for every standard MCP method it defines a mapping for. You only write your own policies for methods or tools where you want different behavior, or for a method with no built-in default. Your own policies are always checked first.
| Methods | Category | Default behavior |
|---|---|---|
tools/list, tools/call | Tools | Checks the caller's identity against the PDP |
resources/list, resources/read, resources/subscribe, resources/unsubscribe | Resources | Checks the caller's identity against the PDP |
prompts/list, prompts/get | Prompts | Checks the caller's identity against the PDP |
completion/complete | Completion | Checks the caller's identity against the PDP |
logging/setLevel | Logging | Checks the caller's identity against the PDP |
tasks/get, tasks/result, tasks/cancel, tasks/list | Tasks | Checks the caller's identity against the PDP |
ping | — | Bypasses AuthZEN |
notifications/* | — | Bypasses AuthZEN |
Write a policy for initialize. The binding defines no default mapping for it and it isn't in the pass-through set, so a route with only the built-in defaults denies it, and no client can complete a handshake.
Use strategy: bypass if you don't want the PDP involved in this method at all, so the request goes through without an allow/deny check.
Write a policy for resources/templates/list. It's a standard MCP client-to-server method with no built-in default.
The AuthZEN-MCP binding requires unknown methods to deny, so a call to it is denied until you add one.
Write your own policy for resources/read, resources/subscribe, and resources/unsubscribe if your PDP is OpenFGA. Their built-in default sends the MCP resource URI as resource.id.
OpenFGA rejects any identifier containing a colon, so a URI like payments://ledger/2026-08 returns an error.
Reshape the URI into an identifier your PDP accepts:
policies:
- match: Equals(`mcp.method`, `resources/read`)
mapping:
evaluation:
subject:
type: "identity"
id: "$token.sub"
action:
name: "resources/read"
resource:
type: "resource"
id: "$params.uri.replace('://', '/')"
That's why the configuration example only defines a policy for transfer_funds.
Every other method call with a built-in default, like tools/list, is already covered.
resources/templates/list and initialize are the exceptions, and only initialize breaks the handshake if you leave it unhandled.
