Blog

Chain-aware Authorization: OPA + MCP for the A2A Auth Problem

10 min readLeanroute Team

Chain-aware Authorization: OPA + MCP for the A2A Auth Problem

TL;DR

AI agents are nothing but APIs on cron with autonomy and reasoning. That means authorization needs to happen at every tool call, not just at session start. And the policy needs to see the full call chain to make sane decisions, otherwise a "read PII" and a "send email" both scoped correctly still add up to a data leak. The pattern I think will become standard is OPA as a sidecar to every MCP server, evaluating each tool call against a Rego policy that treats the call chain as input. I am calling this chain-aware authorization because nobody has named it yet and every agent stack is going to need it.

The Problem That Keeps Coming Up

I keep seeing this in comments on my LinkedIn posts. Someone builds an agent, gives it a bunch of tool scopes, and then discovers that the composition of tools is where all the actual risk lives.

Here is the shape of it. Say your agent has read:crm and send:email. Both scopes are legit on their own. Reading a customer record, fine. Sending an email, fine. But an agent that reads a customer's PII and then sends an outbound email based on what it read? That is the shape of a data exfiltration. Neither scope caught it because they were granted separately, at session start, and never re-checked against each other.

The moment agents can chain tool calls, session-scope auth is done. It sees each call in isolation. It cannot look at what came before. And what came before is often the entire story.

This is the A2A auth problem. And in the projects I have been part of over the last few years, I have not seen anyone solve it cleanly. Everyone hand-rolls something.

Why the Usual Answers Are Bad

Three patterns dominate today, and all three fail the composition test.

One key per session, static scopes. What every framework does out of the box. Grant read:crm and send:email at session start, agent uses them the whole loop. Zero re-authorization between calls. This is the version of "auth" that says "the door was locked when you walked in" and then just gives you the run of the house. Easy to build. Easy to break.

One key per tool, narrower scopes. Better for blast radius, worse for ergonomics. The agent is now juggling 15 credentials. And composition is still unrestricted, because if it needs both tools, it holds both keys. The keys just look nicer.

Framework-native ACLs. LangGraph guards, CrewAI validators, custom middleware in every agent stack. All hand-rolled, all different, all buried in application code that nobody security-reviews. When your compliance team asks "prove which tool calls this agent was allowed to make last quarter," nobody has a real answer. They have a guess and a Slack thread.

None of these treat auth as what it actually is. Auth is a pipeline concern. Not an application concern. The moment you make it the application's job, it stops getting done.

The Lesson From Web APIs (Which We Already Learned)

We solved this exact problem a decade ago for web services. Two patterns won:

Envoy + OPA. Envoy sits as a reverse proxy in front of every service. OPA sits as a sidecar next to Envoy. Every request from outside hits Envoy, which asks OPA "is this allowed?" before forwarding to the service. The service does not know OPA exists. Auth is a network-layer concern.

Istio auth policies. Same idea at the service mesh. Every east-west call gets policy-checked. The mesh does not care what the service is.

The lesson from both patterns is the same. Authorization belongs in a layer that sees every call and knows nothing about what the calls are guarding. If the service has to opt in, it does not get done.

AI agents in 2026 are exactly where web services were in 2016. Every framework rolls its own auth in application code. Every framework's auth is bad. The pattern that fixed it before is going to fix it again.

The Pattern: Chain-aware Authorization

Here is what the stack looks like.

      Agent (LangGraph, CrewAI, Claude Code, whatever)
                        │
                        ▼
                  MCP Server
                        │
                  ┌─────┴─────┐
                  │           │
                  ▼           ▼
              OPA Sidecar  Tool Registry
                  │           │
                  ▼           ▼
             Allow / Deny  Actual Tool
                            (DB, API, filesystem)

Every tool call goes through the MCP server. Before the server dispatches to the actual tool, it calls out to an OPA sidecar with a payload that includes:

  • The tool name being invoked
  • The arguments the agent wants to pass
  • The identity of the agent and the human it acts for
  • The full chain of tool calls made so far in this session
  • Any data returned from previous tool calls the policy might care about (redact or hash if needed)

OPA runs a Rego policy against this input. It returns one of three things:

  • Allow. MCP forwards the call to the tool as normal.
  • Deny. MCP returns an error to the agent, ideally with a policy reason so the agent can adapt.
  • Transform. MCP forwards a modified version. This is the underrated one. The policy can redact fields from the response before the agent sees them, add a filter to a DB query, or rewrite the target of an outbound call.

Now, when I first thought about this, I said "OPA is stateless, we need to make it stateful." I was wrong about the framing. OPA policies are pure functions of their input, and that is actually the whole reason this works. You do not need OPA to become stateful. You need to pass the state as input. The chain is input. The policy stays pure. The state lives one layer up, in the MCP server that appends to the chain as tool calls happen.

That distinction matters because pure policies stay testable, deterministic, and auditable. The moment you try to bolt hidden state onto OPA, you lose all three.

A Real Rego Example

Here is a policy that blocks the composition case I opened with. Read PII, then try to send an email? Denied.

package agent.authz

default allow := false

# Track which sensitive data types the agent has read this session.
# `input.chain` is the array of prior tool calls, oldest first.
sensitive_data_read contains kind if {
    some call in input.chain
    call.tool == "crm.read_customer"
    kind := "pii"
}

sensitive_data_read contains kind if {
    some call in input.chain
    call.tool == "vault.read_secret"
    kind := "credential"
}

# Outbound network tools that could exfiltrate data.
outbound_tool if {
    input.tool in {"email.send", "http.post", "webhook.dispatch"}
}

# The rule. Allow unless the agent has read sensitive data AND is now
# trying to make an outbound call. Neither the read nor the send is
# blocked in isolation. Only the composition.
deny_reason := "read_then_send_composition" if {
    outbound_tool
    count(sensitive_data_read) > 0
}

allow if {
    not deny_reason
}

The agent's call chain gets passed in as input.chain. The current call is input.tool. That is the whole policy. A handful of rules. Testing it is one Rego test file. Auditing it is a five minute code review.

Compare that to "add a check in the LangGraph node that calls email.send," which is what most stacks do today. That check lives in one framework, in one node, in one repo, invisible to security review. If a new dev adds a second email node next month, they will forget the check.

What This Actually Gives You

Interoperable. Any MCP server + any OPA sidecar + any Rego policy. Swap the agent framework, keep the policy. Swap the MCP server, keep the policy. Swap OPA for a home-grown evaluator, keep the same policy interface. Nothing else in the stack cares.

Auditable. Every allow, deny, and transform is a log line with the full input and the policy version. When someone asks "prove this agent could not exfiltrate customer data," you point at the OPA logs. Rego has good tooling here already because enterprise Kubernetes has been running this pattern at scale for years.

Context-aware. The policy sees the whole story. Rules like "no more than three writes to the same table per hour," "no email send after any finance.* read," "no LLM call over $0.10 unless the human approved this session," all of these become one-liners.

Testable. Rego has a first-class test framework. Every rule ships with test cases that fake input.chain. You catch the "oops, this rule breaks the happy path" case before it goes live.

Portable. OPA is a CNCF graduated project. Rego is not going anywhere. Policies you write today will still evaluate in five years. Your bespoke LangGraph guard, on the other hand, is at the mercy of the framework maintainer.

What Is Still Hard

I want to be honest about the tradeoffs, because I hate reading blog posts that pretend the recommended pattern is free.

Latency. Every tool call now includes a round trip to OPA. OPA is fast, usually sub-millisecond for well-written policies, but for agent loops that fire hundreds of tool calls per session, it adds up. Run OPA as a true sidecar in the same pod so the network hop is loopback. Keep policies small. Cache policy decisions where the input is stable.

Policy authoring is a real skill. Rego has a learning curve, and small teams may not have anyone who wants to own it. My workaround is to treat policy authoring as a security-team activity and start from an open-source policy library that you adapt, rather than writing from scratch.

The chain has to be trusted. If the agent lies about its chain (says "no previous calls" when there were 20), the policy is worthless. This is why the MCP server has to be the one appending to the chain and the enforcement point. The agent cannot forge what it does not write.

Cross-agent policies are unsolved. When agent A calls agent B, whose chain is the input? Both. The right answer is probably "merge the callee's chain with the caller's chain, policy sees the union." But this is not a settled pattern yet. If you are shipping A2A today, be honest with yourself that the auth story is provisional.

Where This Sits at Leanroute

We are implementing chain-aware authorization for LLM routing decisions internally. Every LLM call through the gateway is a policy decision. Which provider, at what price, with what scope. The current implementation uses hand-rolled rules and per-key spend caps. We are migrating toward OPA-backed policy so customers can bring their own rules without patching our code.

If you want to try the pattern today, the @leanroute/mcp-server package on npm is a good starting point. It has the MCP hooks you need to insert an OPA call between the tool dispatch and the actual tool. I plan to publish a reference implementation of the full sidecar setup in the next few weeks. If you want the code as soon as it drops, follow the Leanroute GitHub.


Key Takeaways

  • AI agents are nothing but APIs on cron with autonomy and reasoning. Everything we learned about API authorization still applies.
  • Session-scope auth cannot handle composition attacks. Chain-aware auth can.
  • The right primitive is OPA, running as a sidecar to your MCP server. Every tool call gets policy-checked against the full call chain.
  • Rego policies stay stateless, pure, testable, and auditable. The chain is input, not state.
  • The pattern is called chain-aware authorization. Nobody named it. So I did.
  • Interoperable. Portable. Boring in the way infrastructure should be.

About Leanroute

Leanroute is One Gateway for Models and Tools.

Route LLM calls across 13 providers, forward MCP tools, enforce policy at the gateway. One integration point for cost, routing, and authorization.

Learn more at leanroute.dev

AI AgentsAuthorizationMCPOPAA2AAI InfrastructureLLM