Here's the quiet truth about shipping agents inside a real company: the protocol was never the blocker, and neither was the connection. Identity was — and right behind it, the policy that says what that identity may do. A tool your agent can reach but isn't allowed to use is not an integration. It's a liability with a network route.
This is Part 7 of a 15-part deep dive on Model Context Protocol (MCP). Part 6 answered who is calling — authentication, cryptographically. This part answers the harder question: what may they do? Authentication gets you a reachable tool. Authorization is what makes it an allowed one.
TL;DR
| Question | AuthN only (before) | AuthN + AuthZ (after) |
|---|---|---|
| A valid token means… | the tool just runs | identity — a separate decision gates the tool |
| Which tools? | any tool | only tools whose scope the principal holds |
| Which data? | whatever the args say | the tenant from the token (+ RLS) |
| Privilege | the union, granted to all | least privilege per agent |
| Destructive actions | a scope is enough | scope + a fresh confirmation |
| Policy | scattered if checks |
central, auditable, provisioned |
The one mental shift: authentication proves the connection; authorization is the permission. Model the identity, then write the policy — what may this identity do, to this data, right now? Everything else is a reachable liability.
1. Reaching a tool is not being allowed
Once Part 6's RequireAuthorization() passed, every tool simply ran. Authentication was mistaken for authorization.
// BEFORE: a valid token -> the tool executes. "Reachable" was treated as "allowed."
app.MapMcp("/mcp").RequireAuthorization(); // proves WHO — and then nothing else checks WHAT
// AFTER: the validated principal (Part 6) is checked against a policy, per call.
public async Task<ToolResult> InvokeAsync(McpToolCall call, AiPrincipal principal, CancellationToken ct)
{
var decision = await authorizer.AuthorizeAsync(principal, call, ct); // reachable != allowed
if (!decision.Allowed)
{
await audit.DeniedAsync(principal, call, decision.Reason, ct); // a denied call is a signal
return ToolResult.Denied(decision.Reason);
}
return await next(call, ct);
}
Authentication answers "who is calling"; authorization answers "may this caller do this, to this data, right now?" A valid token is a reachable connection — not a permission.
2. Enforce the required scope per tool
Each tool declares the scope it requires; the authorizer checks it against the principal's scopes (baked into the token in Part 6).
// The tool declares what it needs; the authorizer enforces it.
[McpServerTool(Name = "create_report"), RequiresScope("reports:create")]
public Task<ReportQueued> CreateReport(...);
// In the authorizer:
if (call.RequiredScope is { } scope && !principal.Scopes.Contains(scope))
return AuthDecision.Deny($"missing scope '{scope}'");
A read agent's token carries campaigns:read but not reports:create, so the create tool is denied before its handler runs — enforced by the server, not requested in a prompt. Every tool declares a required scope, and a call without it is a 403 in the audit log — never a silent success.
3. Tenant isolation is authorization
The tenant comes from the token and bounds every query; row-level security is the backstop.
// "Can call the tool" and "can read THIS campaign" are TWO decisions. The second is data
// authorization — the tenant from the token bounds the query; RLS enforces it in the store.
var kpis = await campaigns.GetKpisAsync(principal.TenantId, campaignId, range, ct);
// ^ from the validated token, never from arguments
Scope authorization says may call this tool; tenant authorization says may touch this data. Multi-tenant systems leak at the second one. Tenant-bounded queries plus RLS are the reason for zero cross-tenant leaks in six months.
4. Least privilege per agent
Each caller gets the minimal scope set for its job. Nothing more.
BEFORE: every agent -> { campaigns:read, events:read, reports:create, admin:flags, ... }
AFTER (least privilege):
Insights -> { campaigns:read, events:read } read + reason
Reporter -> { campaigns:read, reports:create } read + enqueue a report
Admin bot -> { admin:flags } + step-up narrow + confirmed
External assistant -> { campaigns:read, events:read } read-only, tenant-scoped
Least privilege is what turns a prompt-injection incident (Part 8) into a contained one. The Insights agent has never had a write scope, so no amount of prompt injection can make it create, change, or delete anything — its blast radius is capped at "read data it was already allowed to read."
5. Step-up for destructive tools
Destructive tools (flagged by Part 5's annotations) require the scope AND a fresh confirmation or a step-up token.
// A destructive tool needs the scope AND explicit, fresh confirmation — a scope alone is too much
// standing power to hand an autonomous agent for an irreversible action.
if (call.IsDestructive) // from the tool's annotations (Part 5)
{
if (!principal.Scopes.Contains(call.RequiredScope))
return AuthDecision.Deny($"missing scope '{call.RequiredScope}'");
if (!confirmation.IsFreshlyConfirmed(principal, call)) // a human tick, or a step-up token
return AuthDecision.RequireConfirmation(call);
}
For irreversible actions, a standing scope is too much standing power for something that can be talked into anything. Every destructive mattrx-admin tool requires a step-up confirmation on top of admin:flags.
6. Policy as data, decided centrally
One authorizer evaluates a central policy that maps (principal, tool, resource) to allow/deny, and records every decision.
// Authorization is a policy DECISION, not scattered if-statements. One authorizer, one audit
// trail, policies defined centrally (and, in the enterprise, provisioned via the IdP — Part 11).
public sealed class PolicyAuthorizer(IPolicyStore policies, IAiAuditLog audit) : IAuthorizer
{
public async Task<AuthDecision> AuthorizeAsync(AiPrincipal p, McpToolCall call, CancellationToken ct)
{
var policy = await policies.ForAsync(p, call.Tool, ct); // (principal, tool) -> rule
var decision = policy.Evaluate(p, call); // scope + tenant + step-up
await audit.DecisionAsync(p, call, decision, ct); // every allow/deny recorded
return decision;
}
}
Scattering authorization across handlers means it drifts, can't be audited, and can't be governed. Centralize the decision so "what may this identity do" is one policy you can read, test, and audit. One authorizer fronts all three servers, so "who was allowed to do what, and who was denied" is one query.
The numbers, in one place
| Concern | AuthN only (before) | AuthN + AuthZ (after) |
|---|---|---|
| Valid token → action | tool runs | policy decides, per call |
| Cross-tenant leaks (6 mo) | possible | 0 (tenant from token + RLS) |
| Hijacked-agent blast radius | every scope | the agent's minimal scopes |
| Destructive actions | scope alone | scope + fresh confirmation |
| Denied calls | silent / unlogged | audited 403 (a security signal) |
| Policy location | scattered ifs |
one central, auditable policy |
The model to carry forward
Reaching is authentication; allowed is authorization. The dependency your agent actually has isn't the connection — it's an identity and a policy. Establish the identity cryptographically (Part 6), then decide, on every call, what that identity may do to which data — in code, centrally, and audited.
- Separate can-reach from allowed. Never let a valid token be mistaken for a permission.
- Authorize the data, not just the tool. Scope for tools, tenant for data — both, on every call.
- Least privilege, and step up for destruction. Grant the minimum scopes per agent; require confirmation for anything irreversible.
Originally published on PrepStack. Modeling authorization for your agents and want a second pair of eyes on the scope-and-policy design? Reach me at randhir.jassal[at]gmail.com.
Top comments (1)
Good split on reachable vs allowed — that's the distinction most MCP auth writeups skip. The one place I'd push back is leaning on the cross-tenant track record as evidence that tenant-bound queries + RLS is enough. RLS only guards the query path, which is maybe the easy 80%; the leaks I've watched happen live all came from where it doesn't sit — a cache keyed without the tenant id, a log or trace line that dumps the whole row, an error message echoing another tenant's record, and the tenant claim itself: if token minting doesn't bind it hard, a replayed or swapped claim gets you RLS-blessed access to the wrong tenant. So RLS is the floor, not the proof. Curious whether your authorizer treats the tenant id as trusted input or re-derives it per call.