Authorization at the agent boundary is the first line of defense: before a single token reaches the LLM, the server must decide whether this caller is allowed to invoke this agent at this moment. An interceptor sits in the request path, checks the caller's identity and the policy, and either opens the gate or closes it. It is fast, declarative, and enforces the simplest form of access control--at the entry point. But it has a crucial limit: it cannot know what tool the model will ask for, so it cannot decide whether a specific action is safe. Authorization lives in layers. This article covers the boundary layer--where it sits, how to express policy, what happens when the policy service fails, and where the boundary cannot do the job alone.
Middleware pattern
An A2AServer or any agent HTTP server accepts an Interceptor chain. The authz interceptor runs first, before routing to the agent.
A2AServer server = A2AServer.builder()
.agent(myAgent)
.addInterceptor(new AuthzInterceptor(policy))
.build();The interceptor receives the InvocationRequest--caller identity, target agent, session if any--and the InvocationContext that will carry the run. It can inspect headers (bearer token, mTLS certificate), extract the caller principal, resolve that to a role or permission set, and consult the policy. If the check passes, it returns null and the request proceeds. If it fails, it can return a canned Content response (typically an access-denied message) to short-circuit the invocation. This happens before before_agent callbacks fire, and before the session is even loaded.
Policy expressed once
The policy is a data structure, not code scattered in agent methods. A simple form: role=finance can invoke agent=expense. A richer form: (role=finance OR role=manager) AND tenant=acme AND not embargo=true can invoke agent=expense for resource_type=expense.
Express the policy in a config file, a database table, or a policy service (Rego, OPA, Amazon Verified Permissions, or custom). The interceptor loads it once at startup and queries it on every request. When the policy changes--a role loses the permission, an embargo is lifted--the change flows through immediately without restarting the agent. The policy is the source of truth; agent code does not hardcode "only finance can do this." It is easier to audit, easier to change, and easier to test.
Deny by default
If the policy does not explicitly allow the request, deny it. Fail closed, not open. An absent entry in the policy table, a malformed permission check, a policy service timeout--all result in an access-denied response to the caller. This is the only safe default for a multi-tenant, regulated system. It means requests for uncommon combinations (e.g., a new role type the policy author forgot) will fail loudly rather than silently escalate to a default of "let it through." The cost is that you must grant every permission explicitly, but that is a feature, not a bug.
When boundary authz is not enough
The boundary interceptor can decide who is calling and which agent they are calling. It cannot decide what the agent will do once it runs, because the LLM hasn't acted yet. An agent allowed to run can call any tool it has access to.
Suppose the boundary allows a user with role support_read_only to invoke a refunds agent. The interceptor says yes. But the agent loads tools for issueRefund, voidRefund, and lookupRefund. The LLM might decide to call any of them. The support agent should not issue refunds--only read them. The boundary authz does not know this yet. This is why the boundary is necessary but not sufficient. Authorization happens in layers: the boundary says "you may talk to this agent", a tool-level check says "you may invoke this specific tool with these arguments." The boundary is fast and coarse; tool-level checks are granular and closer to the actual risk.
Two identities in agent-to-agent calls
When agent X calls agent Y, there are two identities in flight: the calling agent and the end user on whose behalf it acts. The boundary must authenticate both, and must verify that the calling agent is authorized to delegate.
A booking agent (running as a service account) calls a payment agent to charge a card. The payment agent's authz interceptor sees caller=booking_agent and user=alice. It must check: (1) Does the booking agent have permission to invoke the payment agent? (2) Is alice allowed to make a payment? (3) Is this particular booking/payment combination sensible in context? The first two are the boundary's job. The third may require deeper context. In practice, the calling agent passes alice's identity in a header, JWT claim, or context field. The boundary must trust that claim only if the calling agent is in a whitelist or has valid mTLS, otherwise any agent can claim to act for any user. This delegation chain--agent → agent → end user--must be verifiable at each link, or privilege escalates silently.
RBAC vs ABAC: models for expressing policy
Role-based access control (RBAC) groups permissions by role. A user has a role (e.g., support_agent, finance_manager), and a role has permissions (e.g., read_orders, write_refunds). Simple, auditable, scales to hundreds of roles. The downside: it ignores context. A support agent has read_orders; they can read any order, even sensitive ones for competitors.
Attribute-based access control (ABAC) decides on attributes of the request, not roles. Example: (user.role == 'support') AND (resource.org == user.org) AND (resource.sensitivity < 'internal') AND (request.time between 9am-5pm UTC). More flexible, but harder to audit--a permission check is now a boolean expression, not a simple table lookup. Hybrid approaches are common: RBAC as the base ("support agents can read order summaries") and ABAC to add context gates ("only from their assigned region").
Choose RBAC if your access patterns are stable and predictable; choose ABAC if context matters heavily. For agents in a regulated industry, log every decision--both allow and deny--so you can audit the rule that matched.
Fail closed, not open
When the policy service is unreachable, the interceptor must still make a decision. The safe choice is to deny the request. The caller gets a 403 or an invocation error; the user sees "the service is temporarily unavailable." The agent does not run.
This has a real cost: if your policy service goes down, no one can use your agents, even end users with valid permissions. But it is the only safe choice. The alternative is to allow the request to proceed ("open when we can't check"), which means an outage in the policy layer becomes an outage of access control--bad actors now have no gatekeeper. If the policy service is a bottleneck, cache the results of recent checks and use stale data for a short window when the service is slow. But when the cache is exhausted and the service is down, deny. Your SLO for the policy service must be higher than your SLO for the agent layer, or you will regularly face the choice: let the request through or fail closed. Most teams choose to fail closed and accept the availability hit.
Boundary vs in-agent vs tool-level checks
Authorization lives in three places, each suited to different questions:
| Layer | Knows | Example |
|---|---|---|
| Boundary | Caller, agent, session | Only finance team can invoke the expense agent |
| In-agent | Agent logic, user context, tool choices | Expenses over $10k require approval; block if policy says so |
| Tool-level | Tool args, current state, fine-grained policy | Only approve expenses for your own region |
The boundary is fast and coarse. The tool level is slow and fine-grained. Most systems use all three: the boundary gates entry, in-agent logic enforces domain rules, and tool-level checks enforce the most granular safety constraints. Don't try to do everything at the boundary; it will bloat into an unmaintainable rules engine. Let each layer do its job.
Decision caching and policy-service reliability
If every invocation hits a policy service over the network, latency adds up. A common pattern: cache the result of the most recent authz decision for (caller_id, agent_id, user_id, resource_id) for a short TTL--perhaps 30 seconds to 5 minutes, depending on how fast your permissions change.
Cache hits are fast. Cache misses go to the policy service. If the service is slow or the network is congested, stale-cache entries let you serve requests while the fresh check is in flight. This is a tradeoff: if a permission is revoked, there is a grace period before cached grants expire. In a high-security context, set the TTL short. In a lower-risk context, you can trade latency for availability.
Monitor cache hit rate and policy-service latency. If the service is a bottleneck, consider pushing policy down to the boundary servers (periodic sync from a central policy store) so you can decide locally. If decisions are highly dynamic, keep the synchronous check and optimize the network path (edge region, direct peering, caching layer).
Multi-tenant resource scoping
If your agents run in a multi-tenant SaaS, a user in tenant A must never see or invoke agents belonging to tenant B. The boundary must tag every principal with their tenant and every resource with its tenant, then check that they match before allowing the call.
This is usually straightforward if tenant identity comes from a bearer token claim or mTLS certificate extension. Harder if tenant is inferred from the URL (a legacy pattern that leaks tenant info) or from a session cookie (where the cookie must cryptographically bind to the tenant, not just encode it). Design for the boundary to receive tenant as a verified claim, not as user input. Otherwise, a token from one tenant can be replayed against another agent's namespace if you're not careful.
Audit records: allow and deny
Log every authz decision. Include: timestamp, caller identity, target agent, policy rule that matched (if allow) or the reason for deny, decision result (allow/deny/error), and latency of the policy check. In regulated industries, keep these records for compliance and forensics.
Do not log only denies--log allows too. It is tempting to log only when something goes wrong, but a complete audit trail includes the benign requests, so you can answer: "Did this user ever call this agent?" or "When did the permission change, and what requests were allowed by the old rule?" For high-volume systems, batch logs and write to a data lake. For lower-volume systems, a SQL table works. Either way, make the logs queryable and immutable.
Negative-path testing
Test the cases where authz should deny: wrong role, missing role, expired token, invalid tenant, policy service timeout. Many teams test the happy path (the allowed request) and assume deny works by default. But a bug in the deny logic--forgetting to set a return flag, accidentally falling through to a default allow--is a security incident.
Write tests: assert that role='guest' cannot invoke agent='expense', assert that user in tenant A cannot invoke agent in tenant B, assert that a timeout in the policy service results in a deny, not an allow. Run these in CI so a mistake doesn't ship to prod. Include edge cases: empty policy, malformed policy, policy that grants no one anything. If you use ABAC, test the boolean expression evaluator--off-by-one errors in date ranges are a classic source of bugs.