
AGENT SECURITY
Why OAuth Alone Isn't Enough for AI Agents
OAuth was built to narrow access and its scopes are still app-wide. What chat:write and drive.readonly really cover, the four narrowing mechanisms the specs already give you, and how to tell a scope problem from an excessive-agency one.
TL;DR
OAuth answers whether a token is valid. It has no opinion on whether the action behind it is a good idea, and that gap is where agents cause damage.
The gap is written into the scope parameter. RFC 6749 defines scope as "a list of space-delimited,
case-sensitive strings" and nothing more, and RFC 9396 says outright that scope "is not sufficient to
specify fine-grained authorization requirements." Slack's chat:write is a workspace-level grant.
Google's drive.readonly is "View and download all your Drive files." Neither can say "this channel"
or "this folder."
The common shortcut treats a working OAuth integration as proof the access question is settled. A valid token and an appropriate action are two different claims, and OAuth only ever makes the first one.
This isn't an argument against OAuth, which is still the right way to get a credential. It also isn't an argument that OAuth left you nothing: RFC 9396 authorization details, RFC 8707 resource indicators and RFC 8693 actor claims each narrow one dimension, and almost nobody uses them.
What to do today: audit which of your scopes are account-wide rather than object-scoped, move to the narrow variant where one exists, and put the per-call check somewhere the token isn't.
Overview
Take a reporting agent connected to a Google Workspace tenant with drive.readonly. It was built to
summarise one team's quarterly folder. On a Tuesday a user asks it to "find anything about the
acquisition," and it does, because drive.readonly is documented as "View and download all your Drive
files" and the agent has no way to know which of those files it was supposed to pretend not to see.
Nothing failed. The token was valid, the scope was granted by the right person, and every call returned 200. What went wrong is that a grant written at the level of an account answered a question that needed one written at the level of a folder.
OAuth's own introduction names this as the problem it was created to solve: third-party applications that "gain overly broad access to the resource owner's protected resources, leaving resource owners without any ability to restrict duration or access to a limited subset of resources." Scope is where that ambition mostly stopped. The mechanics of the grant are covered separately in Slack OAuth in Python; what follows is about what a grant does and does not constrain once the caller decides its own next move.
Identity and validity are not the same question as appropriateness. A token tells you the application was once granted a scope. It says nothing about whether this specific call, right now, is one anyone actually wanted.
What OAuth Assumes
Read RFC 6749's role definitions and the intended shape is obvious. A resource owner is "an entity capable of granting access to a protected resource." A client is "an application making protected resource requests on behalf of the resource owner and with its authorization." The client is a thing, singular, whose behaviour was decided before the token was issued.
Three assumptions follow. A human is present at grant time, the software that calls the API afterwards is predictable, and the scope granted at consent covers everything that software will ever do. An agent breaks all three by design. It reasons about what to call next from the context in front of it, chains calls nobody wrote down at consent time, and finds uses for a scope nobody anticipated, because a scope describes a category of action rather than an action.
A second assumption sits in section 3.3. "The authorization server MAY fully or partially ignore the scope requested by the client, based on the authorization server policy or the resource owner's instructions." The requested scope is a wish; the granted scope is whatever the provider issued, and our code discovers the difference by failing. Tolerable for an integration a human configured once. A real problem for a caller that plans multi-step work against capabilities it assumes it has.
Scopes Are an App Boundary, Not a Task Boundary
Scope is a string list, and RFC 6749 section 3.3 is explicit about it: "The value of the scope parameter is expressed as a list of space-delimited, case-sensitive strings." No object identifier, no constraint expression, no limit clause. Whatever narrowing exists has to be smuggled into the string, which is why every provider's scope catalogue names verbs on nouns and never a specific noun.
Here is what four commonly integrated grants actually cover, read off each vendor's own reference page.
| Grant | What the vendor says it covers | Real boundary | Narrowest alternative the provider offers |
|---|---|---|---|
Slack chat:write | "Send messages as your Slack app" | Conversations the app belongs to. chat.postMessage returns not_in_channel or no_permission otherwise | Nothing below the conversation. chat:write.public widens it instead, because "New Slack apps do not begin life with the ability to post in all public channels" |
Google drive.readonly | "View and download all your Drive files" | The whole Drive. Google classifies it restricted | drive.file: only files "that you open with an app or that the user shares with an app" |
Microsoft Graph Mail.Send | "Send mail on behalf of the signed-in user" | That user's mailbox | An admin-configured application access policy can "limit app access to specific mailboxes and not to all the mailboxes in the organization" |
| GitHub fine-grained PAT | Per-resource read, write or admin | Only the repositories and organisations picked at mint time | The token is already the narrow option, which is why classic scopes are worth leaving |
Table 1 — Four grants, and how far each one actually reaches.
Two rows matter, in opposite directions. Slack's is the trap: the boundary that looks like a permission is really channel membership, so the same scope behaves differently depending on an operational fact nobody tracks. GitHub's spoils the usual excuse, because a fine-grained token names its repositories, so resource-scoped tokens are clearly possible and most SaaS providers just haven't built them.
Google's own guidance says to pick "the most narrowly focused scope possible" and pushes hard toward
drive.file, which is non-sensitive precisely because it cannot enumerate a Drive. We think this is the
most underestimated gap in agent security reviews: a scope reads like a small grant right up until
someone notices it covers the whole account.
The Four Narrowing Mechanisms You Already Have
The specs did not stop at scope. Four mechanisms constrain a grant along four different axes, and choosing between them is the actual engineering decision.
| Mechanism | What it constrains | Where it is enforced | What it cannot do |
|---|---|---|---|
scope (RFC 6749 §3.3) | Category of action | Authorization server at issue, resource server at use | Name an object, amount or recipient |
authorization_details (RFC 9396) | The action, object, amount and privileges | Authorization server, then a resource server that knows the type | Work unless the provider implements your type. Adoption outside open banking is thin |
resource (RFC 8707) | Which resource server the token works at | Authorization server binds the audience; servers check aud | Say anything about what the token may do at the right audience |
| Actor claims (RFC 8693) | Who is acting for whom | Whoever validates the JWT | Bound the action. It records delegation, it does not limit it |
Table 2 — Four axes, four specs. Most agent integrations use only the first.
RFC 9396 is the one to read. Published May 2023, it adds an authorization_details parameter carrying a
JSON array whose objects have type, locations, actions, datatypes, identifier and privileges
fields, and it names scope's ceiling directly: scope "is sufficient to implement static scenarios and
coarse-grained authorization requests, such as 'give me read access to the resource owner's profile.'
However, it is not sufficient to specify fine-grained authorization requirements, such as 'please let me
transfer an amount of 45 Euros to Merchant A.'"
Swap the payment for an agent action and that sentence specifies our problem. "Post to #marketing" is
the fine-grained requirement. chat:write is the coarse-grained one.
{
"authorization_details": [
{
"type": "slack_message_v1",
"locations": ["https://slack.com/api/chat.postMessage"],
"actions": ["post"],
"identifier": "C024BE91L",
"privileges": ["no_mentions"]
}
]
}That request is legal OAuth and Slack will ignore it, because type values are defined per API and no
SaaS vendor outside financial services has defined one. The standard for expressing what an agent may do
exists; the providers it calls have not implemented it. Until they do, the object-level check lives in
whatever sits between the agent and the API.
RFC 8707, February 2020, closes a different hole. Its resource parameter lets a client "explicitly
signal to an authorization server where it intends to use the access token it is requesting," so the
server can restrict the audience "such that the token cannot be used successfully at other resources."
This one has real adoption: the MCP authorization spec makes it mandatory and requires servers to
validate that a token was issued for them.
RFC 9700, January 2025, states the principle all four serve. "The privileges associated with an access token SHOULD be restricted to the minimum required for the particular application or use case," and tokens "SHOULD be audience-restricted to a specific resource server or, if that is not feasible, to a small set of resource servers." A single token reaching an agent's whole tool surface violates both.
OAuth Doesn't Bind an Action to a Person's Intent
OAuth connects a user to an application. It does not connect an action taken with the resulting token back to a person's intent at the moment it happened. For a human-driven app that rarely matters, because a person is clicking the button behind each call. For an agent, the token authenticates the application while the decision came from somewhere else: a misread instruction, a step the agent added on its own, a call that fits the scope and not the request.
RFC 8693, OAuth 2.0 Token Exchange, published January 2020, is where the standards track put the
vocabulary for this. It separates two things people routinely conflate. Impersonation means that "insofar
as any entity receiving such a token is concerned, they are actually dealing with B." Delegation keeps
both parties visible: the act claim "provides a means within a JWT to express that delegation has
occurred and identify the acting party to whom authority has been delegated," and may_act "makes a
statement that one party is authorized to become the actor and act on behalf of another party."
Most agent deployments ship impersonation and call it delegation. The agent holds a user's token, presents it, and the resource server sees the user, which means every downstream log, rate limit and anomaly detector attributes the action to a human who may have been asleep.
Figure 1 — The same request, two identity models. Only one of them tells you afterwards which party acted.
The reason to care is not elegance. Every question asked after an incident (which agent, on whose behalf, under which grant) is unanswerable from an impersonated call, and carrying both identities is most of the real engineering work here.
Consent Once Isn't Authorization Continuously
OWASP named the resulting failure class in the 2025 Top 10 for LLM Applications. Excessive Agency, LLM06, is "the vulnerability that enables damaging actions to be performed in response to unexpected, ambiguous or manipulated outputs from an LLM, regardless of what is causing the LLM to malfunction," split into excessive functionality, excessive permissions and excessive autonomy. That last clause rules something out. The cause of the malfunction is irrelevant: hallucination, injection and an honestly ambiguous instruction all produce the same in-scope destructive call, so a defence that depends on identifying why the model went wrong is defending the wrong boundary.
Figure 2 — OAuth checks token validity on every call. It was never asked whether the call itself was the right one.
A valid token and a correct action are different claims. OAuth only ever makes the first one, and treating it as proof of the second is where agent incidents actually come from.
Telling the Failures Apart
These failures look identical from the application's side. Every one is a 200, and the signal that separates them is not in the response, which is why it has to be recorded deliberately.
| Symptom | Cause | How you tell it apart | Where the fix goes |
|---|---|---|---|
| Agent posted in a channel nobody expected | chat:write covers every conversation the app belongs to | The call succeeded. Check whether the app was invited there, or holds chat:write.public | A per-call target allow-list, not a scope change |
| Agent read a document it should never have seen | drive.readonly is account-wide | Count distinct file IDs per run. A summariser reading three files a day suddenly reads two hundred | Migrate to drive.file |
| One user's request returned another user's data | One shared connection for all users | Compare the acting user on the request against the credential owner. If the logs cannot answer that, you have found the bug | Per-user credentials, resolved at call time |
| A downstream API accepted a token minted for your gateway | Token passthrough | Inspect the aud claim the downstream API received | The MCP rule: a server "MUST NOT pass through the token it received from the MCP client" |
| An injected instruction triggered a real, in-scope deletion | Excessive agency, not a credential failure | Token valid, scope covered it, and the instruction arrived inside retrieved content | Shrink the tool list, confirm irreversible calls. No token change helps |
Table 3 — Five failures that all look like a successful API call.
The last row is misdiagnosed most often, because it arrives looking like a security incident and gets triaged as a credential problem. It isn't one. The token behaved perfectly, and rotating it, re-consenting, shortening its lifetime and moving it to a better vault all leave the failure where it was.
Row four is worth knowing even if you never write an MCP server, because it is the confused deputy problem and turns up wherever one service holds credentials for many. The MCP spec is blunt: servers "MUST NOT accept or transit any other tokens," and one calling upstream must get its own token.
Closing the Gap
None of this means throwing OAuth out. It is still the right way to get a credential, and reinventing token issuance and refresh per integration would be waste. The work sits above OAuth, and three things have to be true on every call that no token can tell you.
The acting user has to be resolved per call, not per connection. One agent serving forty people needs forty credentials rather than one service account with everyone's reach, because a shared credential makes the audit record unable to say who an action was for.
The effective permission has to be the intersection of what the user may do and what the agent may do, evaluated before the call. An agent whose token allows forty tools and whose user is entitled to six should be able to call six.
And the object-level constraint scope cannot express has to be checked somewhere. Today that means our
own code, because RFC 9396 type values for SaaS APIs mostly don't exist.
def authorize(call: ToolCall, agent: Agent, user: User) -> Decision:
# 1. the token's scope is the ceiling, never the answer
if call.required_scope not in agent.granted_scopes:
return Decision.deny("agent scope")
# 2. the intersection: the user must independently hold it
if call.required_scope not in user.granted_scopes:
return Decision.deny("user scope")
# 3. the object-level check OAuth cannot express
if not user.can_reach(call.target): # channel, file, mailbox, row
return Decision.deny("target out of reach")
return Decision.allow(
actor=agent.id, # RFC 8693 act
subject=user.id, # RFC 8693 sub
target=call.target,
)The return value matters as much as the branches. Every allowed call carries both identities and the resolved target, which is what makes rows three and five of Table 3 diagnosable months later. A companion post works through the pattern for agents calling tools over MCP; the shape holds regardless of the protocol underneath.
The permission that matters for an agent is narrower than the one OAuth issued. Scope is the ceiling. The decision is the intersection of the user's entitlement, the agent's entitlement, and whether the named object is inside both.
Where Fabriq Fits
Agentic Fabriq holds credentials per user in a vault and attaches them server-side at the moment of the call, so the agent never handles the secret and no shared service account makes row three of Table 3 unanswerable. An agent's effective tool list is the intersection of the user's scopes and the agent's, and every request carries two identities, the agent and the acting user, into the audit record. Refresh runs behind that boundary with last-used tracking per connection.
What it does not do is reach into the provider. Clearing a vault copy is not the same act as
invalidating a grant upstream, so the call to the provider's revocation endpoint is still yours. And it
cannot narrow a scope the provider only publishes at account level. Nothing can. If drive.readonly is
what you asked for, that is what the grant says, and the object-level constraint has to be applied on the
way through.
Frequently Asked Questions
Can I limit an OAuth scope to one Slack channel? Not through scope. chat:write is documented as
"Send messages as your Slack app" and the effective boundary is the conversations the app belongs to;
chat.postMessage answers not_in_channel or no_permission outside them. The channel-level constraint
has to be enforced by whatever constructs the call.
What is the difference between scope and authorization_details? Scope names a category of action
as a space-delimited string. authorization_details, from RFC 9396, carries a JSON array whose objects
name actions, locations, datatypes, an identifier and privileges, which is how you say "this
object, this amount." The catch is that type values are API-specific and few SaaS providers define
one.
How do I stop a token from working at the wrong API? Send the resource parameter from RFC 8707 so
the authorization server binds the audience, then validate aud at every resource server. The MCP spec
requires both and forbids forwarding a received token upstream.
Does OAuth tell the provider which user an agent is acting for? Not by itself. A bearer token
presented by an agent is indistinguishable from the same token presented by the user, which RFC 8693
classifies as impersonation rather than delegation. Carrying both parties needs the act claim or an
equivalent, and the provider has to look at it.
Should the agent hold the token? We think not, where you can avoid it. An agent that never sees the credential cannot leak it into a prompt, a log or a tool argument, and the call-time injection point is also the only place an object-level check can run.
Does MCP solve this? It solves the token-handling half. The 2025-06-18 authorization spec makes an MCP server a formal OAuth 2.1 resource server, mandates RFC 8707 resource indicators and audience validation, and forbids token passthrough. It does not decide whether a tool call is appropriate.
If the token is valid and the scope covers the call, what went wrong? Usually nothing at the credential layer. OWASP's Excessive Agency category covers exactly this: damaging actions from "unexpected, ambiguous or manipulated outputs," independent of what made the model go wrong. Treat it as a tool-surface and confirmation problem.
Conclusion
OAuth remains the right tool for the job it was built for. Most of the writing that claims it is broken for agents is really complaining that scope is coarse.
Scope is coarse on purpose, and the specs that fix it exist. RFC 9396 names the object, RFC 8707 names the audience, RFC 8693 names both parties, and RFC 9700 says to use all of it. What is missing is provider support and the discipline to put the per-call decision somewhere the token isn't.
So: find every account-wide scope your agents hold and move to the narrow variant where one exists. Resolve the acting user per call. Make the effective permission an intersection rather than a maximum. And record both identities, because the questions asked after an incident are all about who, and a bearer token cannot answer them.
The token proves you were allowed to ask. It never proves you should have. Whatever closes that gap has to live somewhere OAuth doesn't reach.
Sources
All URLs read 2026-09-29.
- RFC 6749: The OAuth 2.0 Authorization Framework — section 1.1 role definitions, section 3.3 on scope as a space-delimited string list and the server's freedom to ignore a requested scope, and the introduction's statement of the over-broad access problem.
- RFC 9396: OAuth 2.0 Rich Authorization Requests — May 2023; the
authorization_detailsparameter, itstype,locations,actions,datatypes,identifierandprivilegesfields, and the statement that scope is insufficient for fine-grained authorization. - RFC 8707: Resource Indicators for OAuth 2.0 — February 2020; the
resourceparameter and audience restriction so a token "cannot be used successfully at other resources". - RFC 8693: OAuth 2.0 Token Exchange — January 2020; delegation versus impersonation, and the
actandmay_actclaims. - RFC 9700: Best Current Practice for OAuth 2.0 Security — January 2025; section 2.3 on restricting token privileges, audience restriction, and restricting tokens to specific resources and actions.
- MCP specification 2025-06-18: Authorization — MCP servers as OAuth 2.1 resource servers, the mandatory
resourceparameter, audience validation, the confused deputy problem, and the prohibition on token passthrough. - Slack
chat:writescope reference — the scope's own one-line description. - Slack
chat.postMessagemethod reference — channel membership,chat:write.public, and thenot_in_channel,channel_not_foundandno_permissionerrors. - Google Drive API-specific authorization and authentication — the text of
drive,drive.readonly,drive.fileanddrive.metadata.readonly, their sensitive and restricted classifications, and the advice to choose the narrowest scope. - Microsoft Graph permissions reference — the
Mail.SendandMail.ReadWritedelegated permission descriptions, and application access policy for limiting an app to specific mailboxes. - Permissions required for fine-grained personal access tokens, GitHub — per-resource read, write and admin levels, and repository and organisation selection at mint time.
- LLM06:2025 Excessive Agency, OWASP Top 10 for LLM Applications — the definition, and the split into excessive functionality, excessive permissions and excessive autonomy.