Blog
September 16, 2026

Six Ways Traefik Hub Enforces One Agentic Refund

This is Part 2 of a two-part series that explores how identity arrives and where the decision is made for agentic workflows. In Part 2, we put a single operation through the four positions where something enforces, six configurations in all: a support agent issuing $75 against one invoice, against a $500 ceiling and a $100 remaining balance.

The second post, Six Ways Traefik Hub Enforces One Refund, puts a single operation through the four positions where something enforces, six configurations in all: a support agent issuing $75 against one invoice, against a $500 ceiling and a $100 remaining balance.

The architectural distinction is explained in Part 1: The Two Questions Behind Every Agent Authorization Standard. Here we implement it against one concrete operation.

That piece asked where a decision can be made at all, including the case where nothing evaluates the call. This one assumes the gateway is in the path throughout, and asks what its policy looks like, so the cut is slightly different: three shapes of access token against two places to decide.

The Operation

Maya works in customer support. She asks the payment agent to issue a refund of $75 USD against invoice inv-123. The agent is acting on her behalf.

Four rules matter, and they have different owners.

  1. The agent needs a valid access token carrying the refunds scope to reach the refund service. The receiving service defines what that scope means.
  2. An individual refund must be positive, in USD, and at or below Maya's $500 per-request ceiling.
  3. Maya must still be assigned to the customer at the moment authorization is evaluated, not merely when her token was issued.
  4. The transaction system must stop refunds exceeding the payment's remaining refundable balance, $100 here, and handle retries without paying twice.

The $500 per-request ceiling and the $100 remaining balance are different constraints with different owners. Two $75 requests satisfy the first and violate the second if both commit. Keeping that straight is most of the point of this piece.

The tool below is named record_test_refund and writes to a test ledger, so the walkthrough can show approvals and denials end to end without moving money. Point the same configuration at your own refund service and nothing about the policy changes.

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "record_test_refund",
    "arguments": {
      "invoice_id": "inv-123",
      "amount_cents": 7500,          // $75.00
      "currency": "USD"
    }
  }
}

Money is in integral cents throughout, so 7500 is $75.00 and 50000 is $500.00. Every example annotates its own amounts, because a policy nobody can read at a glance is a policy nobody reviews.

Six configurations follow. Each one starts with the gateway policy, because the policy is the interesting artifact. The middleware plumbing is omitted.

Reading the Examples

Every block below is labeled with the system it belongs to, because the six configurations are not one artifact. They are policy spread across three places, and knowing which console to open to change a given line is what makes this practical.

Label What it means
Identity provider What your IdP issues, or is configured to issue. You change this in Okta, Entra, Keycloak, or wherever your tokens come from.
Traefik Hub Gateway policy. The rules evaluated in the data path on every call.
Policy engine The authorization model and the grants. OpenFGA here, but Cerbos, Axiomatics, or PlainID sit in the same position.
On the wire Actual traffic between components. Nothing to configure, shown so you can see what each system sends and receives.

A word on policy engine, because the gateway evaluates policy too, and calling one of them "the engine" invites confusion. The split is enforcement against decision. Hub sits in the path and enforces whatever answer comes back. The policy engine holds the rules and the relationship data behind that answer and never touches traffic. AuthZEN's own terms are policy enforcement point and policy decision point, and they map onto those two exactly.

And AuthZEN is not the engine. It is the API between the two, which lets you swap OpenFGA for Cerbos (or some other equivalent policy engine) without rewriting the gateway. Calling something an "AuthZEN engine" gets that backward.

Note: There is no plumbing shown here. Token exchange middleware, validation middleware, and the chains wiring them together are all real and all necessary, and none of them decides anything. Assume throughout that the token has already been validated: signature, issuer, audience, and that it carries an exp at all. RFC 7519 makes that claim optional, so a token issued without one never expires, and that is a thing to fail closed on rather than assume.

Back to Maya’s refund. The six configurations walk the map from post 1 one cell at a time. Three shapes of token answer its first question, how identity arrives: a plain JWT, a JWT with custom claims, and an ID-JAG-derived token. Two places to decide answers its second question: the gateway alone, or the gateway asking a policy engine. Configurations One, Two and Three keep the decision in the gateway and change only the token. Configurations Four, Five, and Six repeat the same three tokens with a policy engine behind the gateway. Read them in order and the ceiling moves every time, which is the question the closing section answers.

Configuration One: A Plain JWT, No Custom Claims

The policy lives entirely in the gateway.

This is where most organizations actually are. Your identity provider issues an ordinary OIDC token carrying identity, scopes, and group membership. 

IDENTITY PROVIDER · Access token payload, decoded

{
  "iss": "https://idp.example.com",
  "aud": "https://refunds.example.com/mcp",
  "uid": "maya",       // the human
  "cid": "payments-agent",         // the calling client
  "scp": ["refunds"],
  "groups": ["support-tier-2"],
  "exp": 1789200000                // 15 minutes after issue
}

These examples use an Okta-style claim profile, where uid is the human and cid the registered client. They are not universal JWT claim names. Substitute whatever your provider issues, and keep it consistent, because the policies and the mapping later both read these two.

TRAEFIK HUB · MCP policy · Amounts in cents, 50000 = $500.00

policies:
  - match: >-
      Contains(`jwt.scp`, `refunds`)
      && Contains(`jwt.groups`, `support-tier-2`)
      && Equals(`mcp.method`, `tools/call`)
      && Equals(`mcp.params.name`, `record_test_refund`)
      && Gt(`mcp.params.arguments.amount_cents`, `0`)
      && Lte(`mcp.params.arguments.amount_cents`, `50000`)
      && Equals(`mcp.params.arguments.currency`, `USD`)
    action: allow
defaultAction: deny

The ceiling is a literal. It is not in a standard JWT, so it lives in the gateway instead. The token contributes identity, scope, and group membership; every constraint on the operation is written in the rule.

One rule with every condition joined, because policies evaluate on a first-match basis. Split these into separate allow rules and a permitted tool passes on the first one, with its amount never checked, and defaultAction: deny will not undo an earlier allow.

What this cannot do
Every member of support-tier-2 gets the same $500 ceiling, because the ceiling belongs to the route rather than the person. Give Maya a different limit from her colleague, and you need a second route.

Configuration Two: A JWT with Custom Claims

The rule stops being about the route and starts being about the person.

Let’s assume your identity provider can add two more claims. One lists the MCP tools this person's agents may call. The other carries the spending ceiling, which is where finance-facing agents start to differ from everyone else.

IDENTITY PROVIDER · Access token payload · Two claims added

{
  "iss": "https://idp.example.com",
  "aud": "https://refunds.example.com/mcp",
  "uid": "maya",
  "cid": "payments-agent",
  "scp": ["refunds"],
  "groups": ["support-tier-2"],     // still here & no longer load-bearing

  "allowed_tools": [
    "record_test_refund",
    "lookup_invoice",
    "list_payments"
  ],
  "max_amount_cents": 50000,        // $500.00 ceiling

  "exp": 1789200000                 // 15 minutes after issue
}

TRAEFIK HUB · MCP policy · Ceiling now read from the token

policies:
  - match: >-
      Equals(`mcp.method`, `tools/call`)
      && Contains(`jwt.allowed_tools`, `${mcp.params.name}`)
      && Gt(`mcp.params.arguments.amount_cents`, `0`)
      && Lte(`mcp.params.arguments.amount_cents`, `${jwt.max_amount_cents}`)
      && Equals(`mcp.params.arguments.currency`, `USD`)
    action: allow
defaultAction: deny

Three things changed. The tool name is checked against a list the token carries rather than a literal, so one rule covers every tool this person may call. The ceiling is ${jwt.max_amount_cents}, so Maya's $500 and her manager's $5,000 flow through the same policy without a second route. This is what Hub calls task-based access control, TBAC, and the mechanism behind it is variable substitution. Anything in the rule written as ${...} is filled at evaluation time from the request or the verified token: ${mcp.params.name} is the tool the agent asked for, and ${jwt.max_amount_cents} is the ceiling the identity provider signed for this person. The rule describes the task. The values come from claims rather than from gateway configuration. That is the point post 1 made about local evaluation: change the ceiling in the identity provider and the policy does not change, because it never held the number.

And the group check is gone, which is easy to read as an oversight. In Configuration One, it was doing real work: the ceiling belonged to the route, so something had to decide which population that route applied to, and group membership was the proxy. Here the token either carries an entitlement or it does not, and defaultAction: deny handles everyone else. The claim is the answer the group was standing in for.

Keep the group checks as a coarse gate in front if you want, and plenty of teams will. Just be clear that it is defense in depth rather than the rule that decides, because maintaining the same decision in two places means depending on both staying in step, and they will not.

What this cannot do.
The claims are a snapshot. Change Maya's ceiling in the HR system and nothing moves until her next token. A short lifetime does not fix this the way it appears to: a fifteen minute token is fifteen minutes of stale entitlement, and shortening it trades staleness for refresh load rather than removing it. And allowed_tools scales with the number of tools, so an agent with access to four hundred of them carries a large array on every request.

Configuration Three: An ID-JAG-Derived Token

The ceiling has nowhere left to live.

Now the credential arrives by federation. The agent presents an ID token, Hub obtains an ID-JAG from the identity provider and redeems it at the resource application's authorization server for that server's own access token.

The scopes it carries are conventional OAuth scopes, defined by the resource application. Okta's own MCP walkthrough uses todos.read mcp.access: a resource scope and a protocol scope. Cross App Access and the MCP extension have no tool-level scope vocabulary, and an ID-JAG doesn't know that tools exist.

Before the payload, one thing about who minted it:

RESOURCE APP'S AUTHORIZATION SERVER · Not your IdP · Access token payload

{
  "iss": "https://resource-as.example.com",
  "aud": "https://refunds.example.com/mcp",
  "uid": "maya",
  "cid": "payments-agent",
  "scp": ["refunds", "mcp.access"],
  "exp": 1789200000                // 15 minutes after issue
}

// No allowed_tools. No max_amount_cents.
// You do not control what this server puts in its tokens.

Identity survives the federation boundary. Entitlements do not.

You can ask a third-party authorization server for a scope it already publishes. You cannot ask it to carry your spending ceiling. So Configuration Two's policy, which depends on ${jwt.max_amount_cents}, has nothing to read.

TRAEFIK HUB · MCP policy · The ceiling returns as a literal

policies:
  - match: >-
      Contains(`jwt.scp`, `refunds`)
      && Equals(`mcp.method`, `tools/call`)
      && Equals(`mcp.params.name`, `record_test_refund`)
      && Gt(`mcp.params.arguments.amount_cents`, `0`)
      && Lte(`mcp.params.arguments.amount_cents`, `50000`)
      && Equals(`mcp.params.arguments.currency`, `USD`)
    action: allow
defaultAction: deny

Configuration One's shape again, reached from the opposite direction. Federation bought reach and cost the per-person ceiling.

Two exceptions. If you own the resource authorization server, which is the case for internal MCP servers, you can configure it to mint those claims, and Configuration Two's policy carries over untouched. And the ID-JAG specification allows structured authorization_details to travel with the grant, so in principle a resource server could reflect a ceiling into its token. In practice, no vocabulary is defined for it, and the vendor on the other end decides what to honor, so it is not something to plan around today.

What this cannot do.
The scopes admit the agent to the refund service and say nothing about which tool or what amount. And because entitlements no longer travel in the token, a per-person ceiling has nowhere left to live inside the gateway. Which is the argument for Configuration Four.

Configuration Four: A Plain JWT Plus AuthZEN

The rule leaves the gateway.

Back to Configuration One’s ordinary token, and this time the gateway delegates the decision to an external party. Which makes this the answer to both dead ends above: Configuration One, where your identity provider will not carry entitlements, and Configuration Three, where it carried them, and they did not survive the federation boundary.

TRAEFIK HUB · AuthZEN mapping · Not a rule

policies:
  - match: Equals(`mcp.method`, `tools/call`)
           && Equals(`mcp.params.name`, `record_test_refund`)
    mapping:
      evaluation:
        subject:
          type: okta_user
          id: $token.uid
        action:
          name: issue_refund
        resource:
          type: invoice
          id: $params.arguments.invoice_id
        context:
          agent: $token.cid
          amount_cents: $params.arguments.amount_cents
          currency: $params.arguments.currency

The gateway policy is no longer a rule. It says which facts to send and where to find them. Identity comes from verified token claims: a string the agent supplies in its own tool arguments is not evidence of who is acting.

ON THE WIRE · Hub to policy engine · Readable identity values shown

{
  "subject": {"type": "okta_user", "id": "maya"},
  "action": {"name": "issue_refund"},
  "resource": {"type": "invoice", "id": "inv-123"},
  "context": {
    "agent": "payments-agent",
    "amount_cents": 7500,            // $75.00
    "currency": "USD"
  }
}

// response
{"decision": true}

Where the Ceiling Went

Into the policy engine's authorization model. The invoice's issue_refund relation requires both customer assignment and a conditional grant.

POLICY ENGINE · OpenFGA · Authorization model

condition refund_limit(agent: string, permitted_agent: string,
                       amount_cents: int, limit_cents: int,
                       currency: string) {
  agent == permitted_agent &&
  amount_cents > 0.0 &&
  amount_cents <= limit_cents &&
  currency == "USD"
}

POLICY ENGINE · OpenFGA · The grant, written by an administrator

{
  "user": "okta_user:maya",
  "relation": "issue_refund",
  "object": "invoice:inv-123",
  "condition": {
    "name": "refund_limit",
    "context": {
      "permitted_agent": "payments-agent",
      "limit_cents": 50000            // $500.00
    }
  }
}

The agent supplies the requested amount. The gateway does not forward a caller-supplied limit_cents. The ceiling comes from the stored grant, which is why changing an assignment or a limit affects later evaluations without reissuing anyone's token.

And the grant is per invoice. Configuration Two could only ever say "up to $500 for anything." This says "up to $500, on this invoice, while you are still assigned to this customer."

What this cannot do.
The policy engine evaluates relationships and conditions it already owns. It does not reach out for a fraud score or a running refund total on its own. And a decision is only as current as its inputs, subject to propagation and caching.

Configuration Five: A JWT with Custom Claims Plus AuthZEN

A cheap gate, then a decision for the calls that matter.

The obvious objection first. If the token already carries the ceiling, why ask anyone?

Because the claim answers a weaker question than it looks like. max_amount_cents: 50000 does not mean Maya may refund five hundred dollars. It means Maya may refund up to five hundred dollars of anyone's money, against any invoice in the system. The claim constrains magnitude. It says nothing about the target.

The grant in Configuration Four is written against invoice:inv-123 and requires that Maya still be assigned to the account that owns it. No claim does the same job, because you would have to enumerate every invoice she may touch and reissue her token whenever an assignment changes.

One caps how much. The other decides against what.

So both run. This is the one place where the order they run in is the policy, so here is the order.

TRAEFIK HUB · MCP policy · Evaluated first, no round trip

refund-gate:
  plugin:
    mcp:
      policies:
        - match: >-
            Equals(`mcp.method`, `tools/call`)
            && Contains(`jwt.allowed_tools`, `${mcp.params.name}`)
            && Gt(`mcp.params.arguments.amount_cents`, `0`)
            && Lte(`mcp.params.arguments.amount_cents`, `${jwt.max_amount_cents}`)
            && Equals(`mcp.params.arguments.currency`, `USD`)
          action: allow
      defaultAction: deny

# evaluated second, only for calls that survived the gate
refund-pdp:
  plugin:
    authzen-mcp:
      # the mapping from Configuration Four, unchanged

A tool the agent may not call, a negative amount, the wrong currency, or anything above the person's own ceiling is refused at the gateway with no round trip. Only a well-formed call from someone plausibly entitled to make it reaches the policy engine, which answers the part about target.

Two further properties fall out of the ordering.

The token's ceiling becomes an outer bound that always holds. The policy engine can be misconfigured, its data can be stale, someone can write a grant that is too generous. None of that lets a refund through above what the identity provider signed for this person. Two independent systems have to agree.

Reserve the expensive path for expensive calls. This is the shape to reach for when someone raises the round trip as an objection. Put a counter on the gate and see how many calls it stops before they reach the policy engine. That number usually ends the argument.

When not to do this. If your policy only cares about which tool and how big, claims alone are correct and the policy engine is cost for nothing. A refund needs both because a refund has a target. Plenty of operations do not.

What this cannot do.
Still nothing about the remaining balance. Two well-formed $75 refunds pass the gate, pass the policy engine, and both arrive at the backend. The layering added a second opinion on permission.

Configuration Six: An ID-JAG-Derived Token Plus AuthZEN

Where the identities stop matching.

Federation and a policy engine together. The gateway policy is the shape you have already seen, and the interesting part is one line of it.

RESOURCE APP'S AUTHORIZATION SERVER · the subject may not be the one your policy engine knows

{
  "iss": "https://resource-as.example.com",
  "aud": "https://refunds.example.com/mcp",
  "uid": "a7f3c1e0-9b2d-4c88-b1aa-77e2f0d41c93",   // their identifier, not yours
  "cid": "payments-agent",
  "scp": ["refunds", "mcp.access"],
  "exp": 1789200000                                // 15 minutes after issue
}

Configuration Three's exceptions were pointing here. Your identity provider knows Maya as maya. The resource application's authorization server issues a subject from its user directory, and the two strings don't need to match. The ID-JAG draft anticipates this. It recommends that the grant carry an email claim, an aud_sub claim, or both, where aud_sub is the resource authorization server's own identifier for the user, and it lets that server use them for subject resolution, including provisioning the account on first contact. The MCP extension defers to the draft on this point. Neither of them makes the link for you.

TRAEFIK HUB · AuthZEN mapping · From Configuration Four

policies:
  - match: Equals(`mcp.method`, `tools/call`)
           && Equals(`mcp.params.name`, `record_test_refund`)
    mapping:
      evaluation:
        subject:
          type: okta_user
          id: $token.uid          # same expression, different value arrives
        action:
          name: issue_refund
        resource:
          type: invoice
          id: $params.arguments.invoice_id
        context:
          agent: $token.cid
          amount_cents: $params.arguments.amount_cents
          currency: $params.arguments.currency

Nothing in the mapping changed. $token.uid is the same expression it was in Configuration Four. What changed is the value flowing through it, and therefore what the policy engine needs to be holding a grant against.

POLICY ENGINE · OpenFGA · The grant, keyed on the federated subject

{
  "user": "okta_user:a7f3c1e0-9b2d-4c88-b1aa-77e2f0d41c93",
  "relation": "issue_refund",
  "object": "invoice:inv-123",
  "condition": {
    "name": "refund_limit",
    "context": {
      "permitted_agent": "payments-agent",
      "limit_cents": 50000            // $500.00
    }
  }
}

Same model, same condition, same relation. A different user identifier. Which means federation moves one question out of the gateway and into your policy engine's data: whichever identity the resource server issues has to be the one your grants are written against, or resolvable to it.

That is not a gateway problem and no policy expression solves it. It is account linking, and it is the work nobody mentions when they describe ID-JAG as dropping cleanly into an existing setup. The gateway does let you choose the anchor. A resource authorization server usually emits more than one identifier: an opaque subject from its own directory, and often an email or another linkable claim. Whichever of those your directory can resolve is the one to key the grants on, and switching between them is one line of the mapping, the expression behind the subject id. That does not do the linking for you. It does mean you are not stuck with whichever identifier happens to land in the claim you first reached for.

What this cannot do.
Everything Configuration Four could not, and one thing more. If the subject the resource server issues is not one your policy engine holds grants for, every call is denied. The denial reads as a policy failure. It is an account-linking gap, and the fix is in the directory or the mapping, not in a wider grant.

What Actually Changed

One question runs through all six and gets a different answer every time. Where does the ceiling actually live?

Configuration Token Decided by Where the ceiling lives
One Plain JWT Gateway The route. Same limit for everyone who reaches it.
Two JWT with claims Gateway The token. Per person, and frozen at the moment it was issued.
Three ID-JAG derived Gateway The route again. The claims did not survive the federation boundary.
Four Plain JWT Policy engine The policy engine. Per person and per invoice, and current rather than frozen.
Five JWT with claims Both Both. The token caps magnitude, the policy engine decides target.
Six ID-JAG derived Policy engine The policy engine, keyed to whatever subject the resource server issues.

The ceiling starts on the route, moves to the person, gets lost crossing a boundary, lands in a system that can hold it properly, and then needs re-anchoring to an identity you do not mint.

Identity survives every transition. Entitlements only survive while you own the thing minting the token.

It explains why Configuration Three loses the per-person ceiling, and Configuration Five does not. A rule reading ${jwt.max_amount_cents} survives only while you control the issuer. A mapping reading $token.uid survives federation untouched, because every authorization server emits a subject.

Configuration Six is the caveat to that. The mapping survives, but the value coming through it may not be the identifier your policy engine holds grants against. Federation moves that question out of the gateway and into your directory.

Moving Between the Configurations

Plain JWT JWT with custom claims ID-JAG-derived token
Gateway decides One. The route. Two. The token, frozen at issue. Three. The route again. The claims did not survive.
Policy engine decides Four. The policy engine, per invoice, current. Five. Both. The token caps size, the engine decides target. Six. The policy engine, keyed on a subject you do not mint.

Moving between them costs less than six configurations suggest. Across the columns of this table, from a plain token to claims to a federated grant, the conditions keep their shape and only the source of the number changes: a literal, then a claim, then a literal again unless you own the resource authorization server. Down the rows, from the gateway to a policy engine, the rule itself moves and the gateway policy becomes a mapping. Put a gate in front of the mapping and the token’s ceiling becomes an outer bound. Federate again and the gate falls away with the claims, leaving the grants keyed on whatever subject the resource server issues.

For MCP servers you run, token claims are the cheapest thing that works, and you should use them. For applications you do not run, federate the credential and put the rules somewhere you still control, because the token is no longer yours to shape.

And none of the six reserves anything. Each one admits an attempt. The system that owns the balance is still the only thing that can stop the second $75 refund against $100 remaining.

Where to Start

Six configurations, and none of the policies run past a dozen lines, which is the part worth noticing. Writing the rule was never the hard bit. Deciding which system is entitled to hold the number inside it is, and that is a question about who owns the issuer and who owns the data, not about which specification you adopt.

If you want an order, here is one. Put something in the path first, reading the claims you already issue, because Configuration One is an afternoon, and it ends the era of an agent being able to call anything it can reach. Federate the credential when you need applications you do not run, and budget for losing your claims at that boundary rather than discovering it in staging. Move the ceiling into a policy engine when the answer depends on which record is being touched rather than how large the number is. Nobody has to do all three, and nobody has to do them in that order.

None of the six will finish the job. Each answers whether this call may be attempted. None of them answer whether the money should move, and the configuration that claims otherwise describes a product rather than a system.

Maya asked for $75 against one invoice. Six ways to answer, and in all six the honest answer is the same: permitted, not yet paid.

Every configuration above runs on Traefik Hub. Point it at one MCP server, write one policy, and watch what your agents have actually been calling. That first log line usually ends the internal debate.

If you run this against a setup we have not, especially a third-party resource authorization server with an identity model of its own, tell us what broke. It helps us map the landscape.

Happy building.

About the Author

Principal Product Manager with 14+ years of tech industry experience, excelling at connecting business needs with technology, driving innovation and strategy. CKA, CPM, CSM, AWS CP, homelabber, former CTO.

Latest from Traefik Labs

The Two Questions Behind Every Agent Authorization Standard
Blog

The Two Questions Behind Every Agent Authorization Standard

Read more
Agentic Telemetry Is Not an Audit Trail: Why We Built the Sovereign Trust Plane
Blog

Agentic Telemetry Is Not an Audit Trail: Why We Built the Sovereign Trust Plane

Read more
The EU Cyber Resilience Act and Your Infrastructure Layer
Report

The EU Cyber Resilience Act and Your Infrastructure Layer

Read more