AI Agent Permissions: Designing Safe Tool Access
#Grant the Action, Not the Agent#link
Every agent design review I sit in on gets the capability question right β what can this thing do? β and skips the one that decides whether it's safe: whose identity does the work? In late August 2025, academic researchers published a demonstration they called the Agent Rental Problem, and it answers the skipped question the hard way. A victim's AI agent, injected through a malicious attachment, gets steered to an attacker's site and completes a 'Sign in with Google' flow on its own β the browser session is the victim's, the app was consented long ago, so the provider answers with a redirect and the attacker walks away holding the victim's authenticated identity. No password stolen, no exploit deployed. It worked because of a permission decision made months earlier: the agent acts as the user, so nothing downstream can tell their traffic apart. The six questions below are the ones worth answering in design review instead of in retrospect, in roughly the order teams hit them.
Whose Identity Is Acting?
Three models exist, and the first is what ships when nobody decides. Passthrough: the agent spends the user's live tokens, inheriting every scope they hold β tempting because it needs no new infrastructure, and disastrous because "the agent can do everything I can do" is the bug, not the feature. Shared service account: one over-scoped identity for the whole agent. A boundary finally exists, but it's a single moat around everything, so one crossing takes all of it. Per-tool scoped principals: each tool gets its own identity, scoped to its one function β small grants, clean logs, and a compromise ceiling that matches a tool's actual job. The rental attack only works under the first model, because completing login as the user was the entire design.
The rental moment β an OAuth authorization with no human in it
Reconstructed from the Agent Rental Problem research. The victim's injected agent loads the attacker's page, which starts a 'Sign in withβ¦' flow. The IdP finds a live session for the victim plus an existing consent grant for the app, so it responds with a redirect β no password, no click, no prompt. The code in the Location header is the victim's identity, delivered.
Notice what the flow required: no stolen credential, no endpoint malware, no protocol flaw. A live IdP session, a prior consent grant for a first-party-looking client, and a browser the agent controls β the provider saw a returning user and answered with a redirect. The same chain against a corporate IdP is worse: the attacker's page becomes a front door that mints the victim's intranet identity, and the session it produces is indistinguishable from theirs in every downstream log.
Skip the identity design and the agent's identity defaults to yours β its tokens, its quotas, its limits, its audit trail. Everything that follows comes from refusing that default: the agent is not a user, and "act as me" is not an authorization model.
| Delegation model | What it grants the agent | Failure ceiling | Bottom line |
|---|---|---|---|
| User token passthrough | Whatever the user can do, live β every scope, every system | The user's entire access, spendable by whatever reaches the agent | Banned by the MCP spec for good reason; eliminate it first |
| Shared agent service account | One static, over-scoped identity across all tools and agents | Everything the account touches β one leak drains the whole wallet | The default most teams ship; plan the migration off it |
| Per-tool scoped principals | Each tool gets its own least-privilege identity for its one job | That tool's legitimate function, nothing else | The design target β more principals, now trivially cheap to manage |
What Should the Agent Be Allowed to Ask For?
Scope, audience, lifetime β get any of the three wrong and even a clean architecture leaks. The MCP authorization spec (2025-06-18 revision) is unusually blunt: servers are OAuth 2.1 resource servers, tokens must be validated, and forwarding a token to a downstream API β passthrough β is banned outright. The spec's stated reasons are the mechanism: a token minted for one service and spent at another breaks audience validation, and once a token is spendable everywhere, attribution dies with it β the audit trail converges on whatever identity the token claims. Audience binding is what makes "this token only works there" true: the client sends a resource parameter (RFC 8707), the issued token carries the audience in its aud claim, and the downstream API rejects anything minted for anyone else. Scope granularity exists off the shelf β GitHub's fine-grained tokens do per-repository, per-permission grants: issues:read on two repos instead of a blanket scope over everything private. When a provider can't express grants at that grain, treat the provider as part of your threat model.
// The agent never holds a user token. It holds a capability:// short-lived, bound to one API, scoped to a named action set.import { SignJWT } from "jose";interface Capability {agent: string; // the actor β "triage-bot-2", never a humandelegatedBy: string; // the human who authorized the session, for auditaudience: string; // exactly one downstream API (RFC 8707)actions: string[]; // ["issues:read", "issues:comment"] β nothing elsemaxRows: number; // enforced by the receiving API, not the agentttlSeconds: number;}export async function mintCapability(c: Capability, signingKey: CryptoKey) {if (c.ttlSeconds > 900) throw new Error("capability exceeds 15-minute ceiling");if (c.maxRows > 500) throw new Error("writes beyond 500 rows require JIT elevation");return new SignJWT({ delegatedBy: c.delegatedBy, caps: { maxRows: c.maxRows } }).setProtectedHeader({ alg: "ES256" }).setSubject(c.agent) // sub = the agent, so attribution survives.setAudience(c.audience) // aud = downstream rejects every other mint.setJti(crypto.randomUUID()) // revocable grant-by-grant.setIssuedAt().setExpirationTime(`${c.ttlSeconds}s`).sign(signingKey);}// The receiving API re-checks scope AND caps on every call.// A capability nothing re-validates is a password with extra steps.
Two traps eat otherwise sound designs. First, audience binding only works if the IdP honors it β several silently drop RFC 8707's resource parameter for legacy clients, so send it, then decode the token and confirm the aud claim actually names your API (the check below automates that). Second, a 15-minute access-token TTL is theater when the refresh token behind it lives for months: time-box and rotate refresh tokens, and make their revocation the real kill switch β that's what mints the next access token after your careful expiry has done its job.
One Identity or Many?
A single shared service account is what everyone ships β one identity, all tools, production credentials, created in an afternoon. Its failure math is brutal: a compromised tool borrows the whole wallet, one leaked secret costs every integration at once, and the logs show one identity doing everything, which makes anomaly detection nearly useless. Per-tool principals invert that. A hijacked tool can do what that tool does β which is its legitimate job β and nothing else. Logs decompose into per-identity streams you can actually baseline: the comment tool hitting the issues API four hundred times a minute is loud when that principal normally posts a few comments an hour. The standing objection is credential sprawl, and it's dated β workload identity federation and modern secrets managers mint short-lived, per-service credentials as configuration. Sprawl with small grants beats concentration with a large one every time you run the numbers. Worth separating from runtime policy, too: what a principal may do is enforcement, who it is on the wire is identity, and you need both β enforcement without identity gives you an unlabeled failure zone.
What Does 'Read-Only' Mean When the Tool Describes Itself?
Permissions are granted against descriptions, which makes the tool manifest the load-bearing document in your whole authorization model β and nobody wrote it under your review. Two failure modes matter. The description can lie at registration: the tool-poisoning demonstrations in early 2025 shipped MCP servers with instructions hidden in description fields β the human approving the tool sees "fetches weather data," and the model reads the rest. And the description can change after approval, because most clients silently re-read the manifest on every reconnect without diffing so much as a line. The controls follow from the mechanism: pin the server to a version or content hash, diff the tool list and every description on each connect, treat any change as a fresh permission request, and keep an inventory of reviewed manifests so re-approval is a decision rather than a reflex.
β οΈ Every grant you make is underwritten by a description, and descriptions are supply chain. The plumbing that carries the grant executes code, too: CVE-2025-6514 (June 2025) turned the mcp-remote bridge's local OAuth listener into OS command execution on macOS and Linux via a crafted authorization response, fixed in 0.1.16. Threat-model the component that holds your tokens like the credential-store it is, and re-read its changelog like your access depends on it β because it does.
How Much Can One Yes Do?
Approval answers "may the agent call this tool?" β the sharper question is "what can one call do?" A tool with no ceiling turns a single hijacked call into a batch job. Put numbers on the danger: row caps enforced by the receiving API from the token's claims (maxRows in the capability above), dry-run as the default mode for anything that mutates state, and read and write split across separate principals so no sentence in the context window can argue a read identity into writing β there is no write scope on the token to escalate to. Hold just-in-time elevation for the rare legitimate bulk write: the agent requests elevation, policy or a human bounds it, and it expires on its own. Enforcement location is what makes a cap real β a limit the agent is merely told about is a suggestion; a limit the API computes from the token is a control.
When It Goes Wrong, Can You Say Who Did It?
Every privileged call should log a triple: which agent, through which tool, on whose delegation. That's the attribution the MCP spec had in mind when it banned passthrough β with forwarded tokens the audit trail converges on the user, and the agent vanishes from its own incident. Build the revocation ladder as well, because stopping an agent mid-incident is a sequence: revoke the capability grant (jti makes it surgical), then the refresh tokens, then disable the tool principal, then the agent itself. Run the test now rather than during the incident: ask your SOC which agent wrote the row they're staring at. If the only honest answer is a person's username, the delegation happened in a chat message instead of in design, and the investigation starts an hour behind.
- βͺFor every agent in production, write down whose token touches each hop. Any passthrough hop goes to the top of the backlog β it's the one fix that removes an entire attack class.
- βͺGive each tool its own principal this sprint; workload identity federation and your secrets manager make per-tool credentials a configuration task, not a project.
- βͺSend resource= on every token request, then decode the token and verify aud β don't take the IdP's word for audience binding.
- βͺTime-box refresh tokens, rotate on use, and wire their revocation into the agent's kill switch; access-token expiry alone is theater.
- βͺSplit read and write into separate principals, put row caps and dry-run defaults on anything that mutates state, and reserve JIT elevation for genuine bulk writes.
- βͺPin tool servers to a version or hash, diff manifests on every reconnect, and treat a changed description as a new permission request.
- βͺLog agent + tool + delegating user on every privileged action, and confirm someone outside the team can answer "which agent did this" from logs alone.
- βͺExpire every agent grant by default and force renewal on a schedule β a permission review that doubles as a liveness check on agents nobody remembers deploying.
π‘ Agents are the first tenant that asks for access in plain English, which is why agent permissions feel like a new discipline. Underneath, they aren't: scoped principals, audience-bound tokens, expiring grants, and manifests you re-verify are two decades of identity engineering arriving late to a strange new coworker. The teams with genuinely safe tool access didn't invent novel controls β they declined to let a chat window count as a user, gave every action its own identity, and made every yes expire on a schedule. Get that right and safe tool access stops being a roadmap item and becomes what it should have been all along: configuration.