Short answer: keep raw access tokens out of prompts and agent state. Authorize the exact action first, request the narrowest scopes the provider supports, fetch or mint a fresh credential at execution time, and pass it only to the server-side callback that performs the call.
Least privilege has two parts here. OAuth scopes limit what a credential can do. Action authorization limits what this user and agent may do now, to this resource, with these arguments. Use both when the provider supports useful scopes. The IETF’s current OAuth 2.0 Security Best Current Practice recommends restricting access-token privileges to the minimum required application or use case, including audience, resource, and action restrictions where supported.
A static token can be enough
const response = await fetch(githubUrl, {
headers: { authorization: 'Bearer ' + process.env.GITHUB_TOKEN },
})
This can be reasonable for an internal, read-only tool with one service identity, a narrowly scoped token, and provider-side controls you already trust. Keep the token on the server and rotate it through your secret manager.
It is a poor default for a multi-user agent. A standing token may outlive the user’s session, carry scopes that unrelated tools can reuse, or be copied into model-visible state. Fetching it early also breaks the causal link between the authorization decision and the credential used for execution.
Resolve the credential under the authorized action
import { Nominee, allow, tokens } from 'nominee'
const nominee = new Nominee({
policy: {
rules: [allow('github.issue.close')],
fallback: 'deny',
},
strategy: tokens(async ({ user, connection, scopes, authorization }) => {
const issued = await credentialBroker.issue({
user,
connection,
requestedScopes: scopes,
authorization,
})
return {
token: issued.accessToken,
expiresAt: issued.expiresAt,
scopes: issued.scopes,
}
}),
})
await nominee.run(
{
tool: 'github.issue.close',
input: { repo, issue },
user: session.userId,
resource: 'repo:' + repo + '#' + issue,
connection: 'github',
scopes: ['issues:write'],
},
({ token }) => github.closeIssue({ repo, issue, token }),
)
The strategy runs only after the policy and resource checks clear and the single-use capability is consumed. The optional authorization object tells a credential broker which action, resource, tenant, policy version, and input fingerprint the credential belongs to.
The scope list is a ceiling, not a magic downscoper. Your provider or credential broker must issue a token that honors the requested scopes. When a strategy reports the scopes on its result, Nominee rejects any declared scope outside that ceiling. It cannot remove privileges from an opaque token the provider minted too broadly.
Check the complete chain
- Storage: keep refresh tokens and long-lived secrets in a server-side vault or encrypted database.
- Timing: resolve the access token after authorization, as close to the side effect as possible.
- Scope: request the provider’s narrowest practical scope and verify what was issued.
- Action: bind user, resource, arguments, tenant, and policy version to the execution decision.
- Lifecycle: refresh and rotate credentials without exposing them to the model.
For why a narrow OAuth grant still needs a per-call decision, read OAuth scopes versus action authorization. For refresh-token concurrency and rotation, see your OAuth refresh is probably broken.
See the boundary run
Run the credentialed action proof and inspect its receipts.