OAuth Scopes Explained: How to Pick the Narrowest Permission That Does the Job
A practical guide to OAuth 2.0 scopes across Google, GitHub, Slack, and more — why over-permissioning delays app review, how to read scope strings, and the narrowest-first strategy that keeps consent screens clean.
OAuth Scopes Explained: How to Pick the Narrowest Permission That Does the Job
When you send a user to Google or GitHub to authorize your app, the consent dialog lists the permissions your app asked for. Most users glance at that list for two seconds — and if it says "Read, compose, send, and permanently delete all email from Gmail," they close the tab. That outcome is entirely within your control, because the scope you request is a choice you make in your OAuth client configuration.
This guide covers what OAuth scopes are, why they differ so dramatically in format across providers, and how to build the habit of requesting the minimum that the task actually requires.
What an OAuth scope actually does
A scope is a string your app sends in the scope parameter of the authorization URL. The authorization server (Google, GitHub, etc.) shows the user exactly what that scope permits, the user approves or denies it, and the resulting access token is cryptographically tied to only those permissions. A token issued with gmail.send cannot read drafts or delete messages — the server enforces that boundary regardless of what your API call asks for.
This is different from user-level permissions. A user who is a Gmail admin can still only grant your app what the OAuth scope permits. Scopes are a deliberate ceiling.
Three things flow from that:
- If a token leaks, its blast radius is bounded by its scopes. A token with
read:user(GitHub profile read) that gets logged in plaintext can only be used to read a name and avatar until it expires — not push code. - Consent screens work better with focused permissions. A 2022 Stripe developer survey found that OAuth consent abandonment rates drop measurably when the permission list is short and specific. Users are more willing to approve "Read your public profile" than "Full access to all repositories."
- App store reviews are gated on scope justification. Google's OAuth verification process holds apps that request sensitive scopes until the developer submits use-case documentation. Requesting
driveinstead ofdrive.fileis one of the most common reasons a Google app stays in "Testing" mode for weeks.
Why Google scopes look like URLs but GitHub scopes are just words
When you first look at a Google authorization URL you see something like:
scope=https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/calendar.events
And a GitHub one looks like:
scope=repo:status read:user
The difference is namespace architecture. Google has hundreds of APIs — Gmail, Drive, Calendar, Sheets, YouTube, Classroom, Workspace Admin — and uses full URI-format scopes to make collisions impossible across all of them. The auth/gmail.readonly path is unambiguous in a way that a bare string gmail.readonly would not be if Google ever added a third-party scope marketplace.
GitHub, Slack, Spotify, Discord, and most smaller providers control their own single-domain scope namespace, so simple dot- or colon-separated strings are sufficient. Both formats behave identically in OAuth 2.0 — they are opaque strings that only the authorization server interprets, as defined in RFC 6749 Section 3.3.
One operational detail trips people up constantly: multiple scopes are space-separated, not comma-separated. The OAuth spec is explicit on this. Comma-separated scope strings are silently mishandled by several providers — some treat the comma as part of the scope name and return a token with no scopes at all. When you check a scope reference like the OAuth Scope Cheatsheet, each entry shows the exact string to copy, formatted for that provider's system.
A worked example: from gmail to gmail.send
Here is what happens when you request two different scopes for the same task — sending transactional email on behalf of a user.
Over-permissioned version:
scope=https://www.googleapis.com/auth/gmail
What this grants (per Google's API documentation): "Read, compose, send, and permanently delete all email from Gmail." Risk rating: Restricted — triggers mandatory manual review, requires security assessment, and puts a persistent warning on the consent screen.
Correctly scoped version:
scope=https://www.googleapis.com/auth/gmail.send
What this grants: "Send email on your behalf." Risk rating: Sensitive — shows on the consent screen as a clear single permission, requires a privacy policy, and processes through automated review in most cases.
Same capability for the sending use case. Completely different security footprint. The restricted gmail scope can read and delete all of a user's email even though your app never calls those endpoints. If your token is stolen by a compromised npm package or a SSRF vulnerability, the attacker has a full Gmail client.
I keep the OAuth Scope Cheatsheet open whenever I start an OAuth integration, specifically to check the risk rating and the exact scope string before I type anything into my OAuth client config. The cheatsheet covers 60+ scopes across Google (Gmail, Drive, Calendar, Sheets), GitHub, Microsoft Graph, Slack, Spotify, Twitter/X, Discord, LinkedIn, and Stripe — with use cases and notes on approval requirements for each.
The app review trap and how to avoid it
The most painful consequence of over-permissioning is being stuck in Google's "Testing" mode. When your app requests any scope in the Sensitive or Restricted categories, Google's OAuth verification process kicks in. Restricted scopes — everything under gmail, drive, and calendar at the full-access level — require a security assessment that can take 4–6 weeks per Google's own verification documentation, and sometimes a third-party audit.
Meanwhile, your app only issues tokens to up to 100 test users that you explicitly allow in the Google Cloud Console. Any other user who tries to authorize sees a warning screen they cannot dismiss without clicking through a series of "I understand this app is unverified" dialogs. For a side project or internal tool, you can live with test mode. For a consumer app, it is a launch blocker.
The escape hatch is always scope narrowing:
- Replace
drivewithdrive.file(only files the app created or the user opened with it) - Replace
gmailwithgmail.sendorgmail.readonly - Replace
calendarwithcalendar.events(no settings or calendar list changes)
None of these alternatives trigger Restricted status. They still require a published privacy policy, but they skip the security audit path and process through automated review, usually within a few days.
The same principle applies to GitHub. The repo scope grants "Full control of private repositories" — which includes deleting them, per GitHub's scope documentation. For a bot that only needs to post comments, public_repo or a fine-grained personal access token with pull_requests:write is the right tool. The repo scope exists, and there are legitimate reasons to request it, but "I needed to read branch names" is not one of them.
The narrowest-first strategy
The practical approach is to start with the minimum and step up only when the API returns a 403.
- Write down what your feature actually does. "Display the user's name in the navbar" does not need
email— it needsprofile(orread:useron GitHub). - Look up the scope that covers that exact action. Use a reference with risk ratings so you can see the review implications before you commit to a scope.
- Ship with that scope. If an API call fails with 403, the error response usually names the missing scope or permission — use that as your next scope to add, not your intuition about what might be needed.
- For features you plan to build later, use incremental authorization: request only what you need at sign-in, then prompt for new scopes the first time the user triggers a feature that needs them. Google, GitHub, and Microsoft all support this pattern.
This approach keeps your initial consent screen minimal, which increases sign-in conversion, reduces the scope footprint in case of a credential leak, and avoids triggering manual review before you are ready for it.
When you debug OAuth-related 401s and 403s, the fastest first move is to decode the token and check which scopes it actually holds. If you issued the token and something still fails, the scope in the token often does not match what you intended. The JWT Decoder reads the scope claim directly from the token payload — in Google's access tokens it is a space-separated string of granted scope URIs — and shows you exactly what the authorization server included when it issued the token.
A quick note on token security
OAuth tokens are credentials. Regardless of how narrow the scope is, a leaked token can be replayed by anyone until it expires. The standard defensive measures apply: store tokens server-side (not in localStorage), use short expiry windows with refresh tokens, and rotate credentials if exposure is suspected. Scope narrowing is not a substitute for token security — it is a second line of defense that limits what can be done with a stolen token.
Made by Toolora · Updated 2026-06-17