"We already scoped the token down — repo, not admin:org. Isn't that enough?" It's a fair question, and it's the wrong axis. A scope answers what category of thing was this credential ever allowed to do. It's decided once, at consent, by a human looking at a permission dialog they may not fully parse, and then it's true — unmodified — for the entire lifetime of the token. An agent asking a tool to run isn't asking a category question. It's asking: may this exact call happen, right now, with these exact arguments. Those are different security properties. A narrower scope doesn't turn one into the other.
What a scope actually promises
Say a user connects GitHub with repo scope so an agent can close issues. That scope is now true for every repo the user can access, every write operation repo covers — not just issues:write on the one repository the agent was supposed to touch — for as long as the token or its refresh chain lives. Nothing about the scope changes based on which tool call is about to use it. If the model is convinced by an injected instruction to force-push to a different repo entirely, the scope doesn't object. It was never asked a per-call question; it can't answer one.
That's not a defect in OAuth. Scopes were designed for a human clicking "Allow" once, trusting an app to behave for the rest of the session. An LLM deciding on every turn which tool to call with which arguments is a different trust model, and the scope was never upgraded to match it.
What action authorization checks instead
nominee's decision-bound lifecycle — prepareAction() → approval → executeCapability(), or run() as the one-call shorthand — treats the credential as the last step of a per-call decision, not a standing grant:
const prepared = await nominee.prepareAction({
tool: 'refund.create',
input: { orderId, cents },
user: session.userId,
tenant: session.tenantId,
resource: `order:${orderId}`,
connection: 'payments',
scopes: ['refunds:write'], // the ceiling — not what gets requested upstream
})
if (prepared.status === 'pending_approval') { /* … */ }
await nominee.executeCapability(prepared.capability, { orderId, cents }, (async ({ token }) =>
payments.refund({ orderId, cents, token, idempotencyKey: prepared.action.id })
))
Four things happen here that a scope, however narrow, doesn't do on its own:
- The credential doesn't exist yet when the call is authorized.
prepareAction()checks policy and — when aresourceis named — the application authorizer againstorder:${orderId}specifically, not "orders in general." Only after that clears, and only after a single-use capability is issued and atomically consumed, doesexecuteCapability()resolve a token at all. - The permission is rechecked immediately before execution, not just at consent. From
docs/production.md: "Nominee calls the application authorizer while planning and again after capability consumption, immediately before credential resolution and tool execution. A permission revoked while approval was pending therefore fails closed without running the tool." An OAuth scope has no equivalent second look — revoke it mid-flight and whatever call is already using the cached token completes anyway. - The scope you pass is a ceiling the strategy cannot exceed, enforced in code. If the credential strategy hands back anything outside the requested scopes,
boundToken()inpackages/core/src/nominee.tsthrows before the token reaches the tool:"nominee: strategy '{name}' returned scopes outside the action ceiling". The application declares the maximum for this action; the strategy can narrow it further, never widen it. - The credential is bound to the exact input that was authorized. Change
orderIdorcentsbetweenprepareAction()andexecuteCapability()and the call is rejected withAuthorizationInputChangedErrorbefore the token is ever fetched — a fingerprint of the arguments, not just the tool name, gates the credential.
And the capability itself is short-lived on purpose — issued once, expires in minutes (capabilityTtlMs, default five), and can be consumed exactly once via an atomic consumeCapability(). It isn't a bearer token you could accidentally reuse for a second, different call; a second attempt with the same capability fails by construction. An OAuth access token, by contrast, is valid for its full lifetime against every call that fits its scope — reuse is the normal case, not an edge case to guard against.
Scoped down still isn't checked per call
The honest version of this argument has to admit: fine-grained OAuth scopes help. issues:write on one repo is safer than repo on all of them, and if your provider offers that granularity, use it — nominee's strategy layer sits on top of whatever scopes your provider actually supports, it doesn't invent new ones. But even the narrowest real-world scope is still a standing grant, decided once, good for the token's whole life. It doesn't know the difference between call #1 and call #400, and it can't be rechecked against a resource permission that changed an hour after consent. Those are the properties action authorization adds — not a replacement for scoping, a layer underneath it.
Side by side
| Property | OAuth scope | nominee action authorization |
|---|---|---|
| Decided | Once, at consent | Per call, at prepareAction() |
| Bound to exact input | ✗ — same token for any call in scope | ✓ — hash of the exact arguments |
| Rechecked after a pause | ✗ | ✓ — before credential resolution |
| Scope ceiling enforced in code | Trusts the provider's grant | ✓ — throws if a strategy exceeds it |
| Credential lifetime | Minutes to months, reusable | Minutes, single-use capability |
| Revocation takes effect | On next provider-side check, if any | Next resolved call — call-time, never cached past expiry |
When a scope alone is fine
If a tool is read-only, if the scope your provider offers is already resource-specific (not just category-specific), and if you don't need proof of what was authorized for a compliance or incident review — plain scoped OAuth is genuinely enough, and adding a decision-bound layer on top is overhead you don't need yet.
Reach for action authorization once a tool can write, once "this user, this tenant, this resource" is a real question your application already has an answer to, or once you need the credential itself to be a consequence of a specific, logged decision — not a standing grant an agent reaches for on its own schedule.
See it for yourself
Bind the credential to the decision, not the session.