Winslow Homer painting of a ship navigating stormy seas
← Back to blog

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.

•Nov 25, 2025•Updated Sep 29, 2026•15 min
OAuthAgentsSecurity

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.

GrantWhat the vendor says it coversReal boundaryNarrowest 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 otherwiseNothing 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 restricteddrive.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 mailboxAn admin-configured application access policy can "limit app access to specific mailboxes and not to all the mailboxes in the organization"
GitHub fine-grained PATPer-resource read, write or adminOnly the repositories and organisations picked at mint timeThe 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.

MechanismWhat it constrainsWhere it is enforcedWhat it cannot do
scope (RFC 6749 §3.3)Category of actionAuthorization server at issue, resource server at useName an object, amount or recipient
authorization_details (RFC 9396)The action, object, amount and privilegesAuthorization server, then a resource server that knows the typeWork unless the provider implements your type. Adoption outside open banking is thin
resource (RFC 8707)Which resource server the token works atAuthorization server binds the audience; servers check audSay anything about what the token may do at the right audience
Actor claims (RFC 8693)Who is acting for whomWhoever validates the JWTBound 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.

json
{
  "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.

delegation, what RFC 8693 describes

sub=user, act=agent

agent

SaaS API

log says: agent did this for user

impersonation, what most deployments ship

user's token

agent

SaaS API

log says: user did this

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.

right action? right data?
right moment?

human clicks consent, once

token issued
scope fixed at grant time

agent call 1
token valid -> runs

agent call 2
token valid -> runs

agent call N
token valid -> runs

OAuth has no opinion

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.

SymptomCauseHow you tell it apartWhere the fix goes
Agent posted in a channel nobody expectedchat:write covers every conversation the app belongs toThe call succeeded. Check whether the app was invited there, or holds chat:write.publicA per-call target allow-list, not a scope change
Agent read a document it should never have seendrive.readonly is account-wideCount distinct file IDs per run. A summariser reading three files a day suddenly reads two hundredMigrate to drive.file
One user's request returned another user's dataOne shared connection for all usersCompare the acting user on the request against the credential owner. If the logs cannot answer that, you have found the bugPer-user credentials, resolved at call time
A downstream API accepted a token minted for your gatewayToken passthroughInspect the aud claim the downstream API receivedThe MCP rule: a server "MUST NOT pass through the token it received from the MCP client"
An injected instruction triggered a real, in-scope deletionExcessive agency, not a credential failureToken valid, scope covered it, and the instruction arrived inside retrieved contentShrink 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.

python
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.