AI Workflow Auth Scopes: Granular OAuth for Internal Agents
Least-privilege OAuth for an AI agent is harder than picking the smallest scope. Google's Gmail scopes are coarse, narrowing a scope in code does not shrink grants you have already issued, and the smaller scope may not exist for what your tools do. This post covers how scopes behave in practice, with examples from Google's own documentation.
The narrow scope is often not narrow
A typical recommendation is to swap a broad Gmail scope for gmail.metadata or gmail.readonly. Google's Gmail scope list classes gmail.metadata, gmail.readonly, gmail.compose and gmail.modify all as restricted, and only gmail.send as sensitive. The OAuth consent documentation says restricted scopes require a security assessment on top of app verification, and that apps used only inside your own Workspace organization skip further Google review for sensitive or restricted scopes. So for an agent that serves people outside your organization, shrinking from modify to readonly reduces what a stolen token can do and leaves the verification burden unchanged. For an agent used only by your own staff, an internal app avoids that burden. Decide the user type before you decide scopes.
Scopes cannot express what your tools do
Scopes name API surfaces, not intents. Google Ads has one scope, adwords, for both reading campaign performance and changing budgets. Gmail has no scope for one label.
Audit declared scopes tool by tool. Every cut should come from removing a tool, choosing a read-only variant of an API, or deleting a scope that another one already covers. There is also a trap on the user side. Google's granular-permissions page says that when an app requests several scopes at once, users may not grant all of them, and the app "must check what scopes are granted" and cannot assume users grant everything requested. An agent that assumes the full set will fail halfway through a task on the one tool whose scope the user unticked, so read the granted scopes after consent and switch off the tools that depend on the missing ones (How to handle granular permissions).
The practical rule is that a scope sets the ceiling, and the ceiling for Gmail and Ads is high. Real least privilege comes from a layer above it: per-service read and write switches, per-tool switches, and approval for writes. Hold these as flags a workspace admin can set, and check them right before each tool runs.
Request scopes one tool family at a time
Do not ask for Gmail, Drive and Calendar at first sign-in. Give each Google tool family its own service key with only its scopes, each stored as its own token row, sharing one OAuth client. A tool with no grant raises a "needs auth" signal, and the Connect button then requests only that family's scopes. Connecting Drive grants nothing about Search Console, and revoking one family leaves the others standing. The split lives in the grant, not the app. This follows Google's own advice not to request access to data at first authentication unless it is essential, though it does not avoid verification, since every scope the project can request must still be declared and verified.
# one OAuth client, one token row per tool family
SERVICES = {
"gmail": ["https://www.googleapis.com/auth/gmail.modify"],
"gcal": ["https://www.googleapis.com/auth/calendar.events"],
"gsc": ["https://www.googleapis.com/auth/webmasters.readonly"],
}
# a tool calls token_for("gsc"); with no row it raises NeedsAuth(service="gsc")
Narrowing scopes does not narrow existing grants
Grants made before a code change keep their wider scopes and keep working, with no reconnect. If you tighten the scope list, users and service accounts that consented earlier still hold broad refresh tokens until they are revoked or re-consent. Track the scopes stored with each token, compare them to what the current code asks for, and force reconnection (or revoke) for any token that holds more. This is the usual gap between "we reduced our scopes" in a ticket and what a leaked token can do in production.
Refresh tokens expire in ways that look like agent bugs
Google's OAuth 2.0 documentation lists several. An external-type project left in Testing status issues refresh tokens that expire after seven days, which kills a pilot agent each week for no visible reason. Tokens also die when the user revokes access, after six months unused, and when the user changes their password if the token holds Gmail scopes. Each Google account is limited to 100 refresh tokens per OAuth client, and exceeding it silently invalidates the oldest. Build the failure path deliberately: on a failed refresh, the agent should say what it could not do, offer the Connect prompt again and carry on. Alert when refresh failures cluster, because that points to a project setting and not to individual users.
Keep credentials out of the model's reach
In a LangGraph or any other agent framework, keep the token out of shared state. Fetch it at the point of use inside the tool that calls the API, from a secret manager, and return only the data the model needs. A node that runs the model should not be able to read the token even if a prompt tells it to. Limit which hosts a credentialed fetch may reach, too: a vendor-supplied file URL can redirect anywhere, so the download helper should check each hop against a fixed allowlist, follow redirects by hand with a cap, and send the credential only to allowlisted hosts.
Audit and test
- List every token with its stored scopes, owner and last use, and compare against current code. Revoke what is broader than needed.
- Write a test that fails when a tool needs a scope that no service entry grants, and when a new tool has no read or write classification.
- Review the identity provider's OAuth audit logs for the agent's client and alert on scope use outside the tool list.
- Move the pilot out of Testing status before it matters, and rehearse a revoked-token run.
References
- Gmail API scopes
- Google Workspace: configure the OAuth consent screen
- Using OAuth 2.0 to access Google APIs
- LangGraph
- Google: How to handle granular permissions: users may grant only some requested scopes, and the app must check what was granted.