OAuth for Agents: Acting on Behalf of a User
A service account with everyone's access puts your entire access-control model inside a model's judgment. Per-user tokens move the check to where prompt injection can't reach it.
An agent that reads a user's calendar, files tickets in their name, or posts to their workspace needs their authority, not yours. The naive approach — a service account with broad access, plus the agent being careful about whose data it touches — puts the entire access-control model inside a model's judgment.
Delegated authorization exists for exactly this, and applying it to agents raises a few problems that ordinary web apps don't have.
Why the service account is the wrong default
A service account with access to everyone's calendars means every request is technically permitted, and only the agent's reasoning decides which data is appropriate. One prompt injection, one confused instruction, one bug in how the user ID flows through, and the boundary is gone — with no log distinguishing legitimate access from a mistake, because both look identical at the API.
Per-user tokens invert this. A request for someone else's calendar fails at the API, no matter what the agent was persuaded of. The check happens where the model can't reach.
What changes for agents
Consent has to cover asynchronous action. A user authorizing a web app expects it to act while they're using it. An agent may act tomorrow, on a schedule, or triggered by an event. That's a different thing to consent to, and the consent screen should say so plainly — the scopes are the same but the expectation isn't.
Refresh has to be robust. Interactive apps re-prompt when a token expires because the user is right there. A background agent hitting an expired refresh token at 3am has nobody to ask. Refresh must happen ahead of expiry, failures must be distinguishable from other errors, and a revoked grant must produce a clear "this needs re-authorization" path rather than a generic failure.
Scopes should be per-task, not per-agent. Agents are usually built to do many things, so the temptation is to request the union of every scope any workflow might need. ⚠️ That maximizes what a compromised token yields. Where the provider allows incremental authorization, request scopes as tasks need them.
Tokens must never enter the context. A token in a message array can be summarized, logged, or exfiltrated by a successful injection. The tool layer attaches credentials from a secret store, keyed by the run's user; the model never sees one and cannot be tricked into revealing it.
Wiring it through the run
The user identity has to reach the tool layer without passing through the model:
run_context = { user_id: "u_8821", ... } # established at run start
# never a tool parameter
tool_dispatch(call, run_context):
token = tokens.get_for(run_context.user_id, provider_for(call))
return provider.invoke(call, auth=token)
The critical property: user_id comes from the session that started the run, not from anything the model produces. If the model can specify whose behalf a call is on, untrusted content can specify it too — and you've rebuilt the vulnerability with extra steps.
Multi-user and background runs
A run acting for several users — comparing calendars across a team, say — needs each call authorized by the relevant person's token, and needs to handle the case where one person hasn't authorized. Degrade explicitly ("3 of 5 calendars available; two members haven't connected") rather than silently omitting.
A scheduled run acts when the user isn't present. Two requirements: a stored grant that's still valid, and a record connecting the action to the authorization that permitted it. ✅ "This action was taken under the grant given on March 3rd for scopes X and Y" is what makes an audit possible, and it's the thing service-account architectures can never produce.
The audit trail
Every action taken on someone's behalf should record: which user, which grant, which scopes, which tool, what arguments, and what the agent was doing at the time.
This isn't only for compliance. When a user asks why something appeared on their calendar, the answer needs to be a specific run, a specific trigger, and a specific grant — not "the agent decided to." The difference determines whether the incident is explainable.
🔍 Testing the boundary
- A run for user A requesting user B's data → must fail at the API, not be filtered afterwards.
- An expired token mid-run → must refresh transparently, or fail with a distinguishable, actionable error.
- A revoked grant → must produce a re-authorization prompt, not a mysterious permission error.
- An injection attempting to change whose behalf the run acts on → must be structurally impossible, because identity isn't a model-supplied parameter.
That last test is the one worth writing first. If it can't be made to pass structurally, the design needs changing rather than hardening.
The takeaway
Use the user's authority, not a service account, and let the provider's own access control do the enforcing. Keep identity out of the model's reach, keep tokens out of the context, refresh ahead of expiry, request scopes per task, and record which grant authorized every action. Then a compromised context produces a failed API call instead of a data leak.