Designing OAuth Scopes and Consent Screens People Actually Read
How to design a scope taxonomy that survives five years of product growth — granularity, naming, incremental authorization, consent screen copy, and the downgrade and revocation paths most APIs never build.
If you are building an API that third parties integrate with, you will eventually design a scope taxonomy. It will feel like a naming exercise. It is not — it is a public API contract that is nearly impossible to change once anyone depends on it, and it determines whether the consent screen your users see is a meaningful security decision or a dialog they dismiss by reflex.
Most scope designs fail in one of two directions. Too coarse, and every integration asks for total access, which trains users that the consent screen is meaningless. Too fine, and the consent screen becomes a wall of thirty checkboxes that nobody reads, which trains users that the consent screen is meaningless. The interesting question is how to land in between.
What a scope actually is
A scope is a restriction on a token, not a grant of permission to a user. This distinction is the source of the most common security bug in OAuth-protected APIs.
The rule: the effective permission is the intersection of what the user is allowed to do and what the token is scoped for. A token with admin:write held by a user who is a read-only member of an organization must not permit writes. Scopes narrow; they never widen.
Stated that way it sounds obvious. In implementation it is routinely violated, because the scope check and the permission check live in different layers and one of them gets skipped. The pattern that prevents it is to make both checks structurally unavoidable in the same place:
function authorize(token, user, resource, action) {
if (!tokenHasScope(token, requiredScope(resource, action))) throw new Forbidden();
if (!userCan(user, resource, action)) throw new Forbidden();
// Neither check alone is sufficient. Both, always, together.
}
A related trap: scopes are not roles. Roles describe a person's standing in your system and change when their job changes. Scopes describe what one particular integration is permitted to do on their behalf. Modelling scopes as roles produces a token whose meaning changes when the user is promoted, which is not what anyone wants — see RBAC vs ABAC for the permission model underneath.
Granularity: the decision that ages
The practical heuristic: a scope should be the smallest unit a reasonable integration would ask for, and no smaller.
If no real integration would ever want to read invoices without reading payments, those do not need to be separate scopes. If a large category of integrations wants read access and a small category wants write, that split is essential — read/write is the single highest-value axis, because it is the one users intuitively understand and the one that most changes the blast radius of a compromised token.
The pattern that has emerged across most large APIs and holds up well is resource:action:
users:read contacts:read billing:read
users:write contacts:write billing:write
webhooks:manage analytics:read admin
Three notes on this shape.
Nesting should imply inclusion, consistently. If users:write implies users:read, that must be true everywhere and documented. Half-implied hierarchies where some writes include reads and others do not are worse than no hierarchy.
Reserve a wildcard carefully or not at all. A * or admin scope is enormously convenient for your own first-party clients and a permanent temptation for third parties. If you ship one, gate it behind manual review, and make the consent screen for it visually distinct and alarming.
Never encode identifiers in scopes. project:123:read seems elegant and creates an unbounded scope namespace, breaks caching and comparison, and blows past the practical length limits of an authorization URL for a user with many projects. Resource selection belongs in the grant — a record of which projects this authorization covers — not in the scope string.
Naming rules
You cannot rename a scope after third parties ship code containing the string. Get these right on day one.
- Lowercase, one delimiter, one convention. Pick
:or.and never mix. - Nouns for resources, verbs for actions, both singular or both plural — consistently.
user:readandcontacts:readin the same API is the kind of inconsistency developers will complain about for a decade. - Do not name after your internal services.
svc-identity:readleaks your architecture and becomes wrong the moment you reorganize. - Do not name after UI features. Features get renamed; the underlying resource rarely does.
- Avoid negations.
users:no-deletecannot be composed with anything.
Version the scope set, not individual scopes. When you need a genuinely different taxonomy, introduce it alongside the old one with a mapping, and deprecate on a published timeline. Silently changing what an existing scope permits is a security incident, not a release.
Incremental authorization
The default behaviour of most integrations is to request every scope they might ever need, at signup, in one screen. This is the single biggest reason consent screens get dismissed unread: the request is enormous, it arrives before the user has any reason to trust the app, and none of it is connected to something they were trying to do.
Incremental authorization inverts this. Request the minimum at first contact — usually just identity — and request additional scopes at the moment the user takes the action that needs them.
The user asked to export contacts, and is being asked for permission to read contacts. The consent is legible because it has context. Approval rates for in-context incremental requests are dramatically better than for up-front bundles, and — more importantly — the approvals mean something, because the user actually evaluated them.
To support this as an API provider you need two things. Your authorization endpoint must accept a request for additional scopes from an already-authorized client and return a token carrying the union of previously granted and newly requested scopes — not just the new ones, which would silently downgrade the integration. And your consent screen must clearly distinguish "already granted" from "newly requested," so returning users are not re-reading a list they approved months ago.
Writing the consent screen
The consent screen is the only place a user gets to make an informed decision. Most are written by engineers listing scope identifiers.
Describe consequences, not permissions. "Read your contacts" is a permission. "See the names and email addresses of everyone in your address book" is a consequence. The second is what lets a person judge.
Order by risk, not alphabetically. Anything that writes, deletes, sends on the user's behalf, or touches money goes at the top, visually distinguished. Read-only identity scopes go last. A screen where the destructive item is buried between two innocuous ones is not obtaining informed consent.
Say who is asking, verifiably. The application name, the verified domain, and — critically — whether you have reviewed this app. An unreviewed app should look unreviewed. Google's "unverified app" interstitial is annoying by design, and it is the right call.
Say how long. "This access lasts until you revoke it" is very different from "for the next hour," and users have no way to know which unless you tell them.
Provide the exit. A link to where the user can review and revoke, on the consent screen itself, before they approve.
Do not pre-check optional scopes. If a scope is optional, it is off by default. A pre-checked optional scope is a dark pattern, and it is the reason regulators pay attention to consent flows.
The paths nobody builds
Three flows exist in the spec, in user expectations, and almost never in the implementation.
Partial approval. A user should be able to grant three of the five requested scopes. This is real work — your consent screen needs per-scope controls, your token issuance needs to honour a subset, and, hardest of all, the integrating application has to handle a token narrower than it asked for. Most do not: they assume the granted set equals the requested set, call an endpoint, and crash on a 403. As an API provider you can make this better by always returning the actually-granted scope in the token response, documenting loudly that it may differ from the request, and returning a 403 body that names the missing scope so the client can prompt for it. If you cannot support partial approval, at least be explicit that consent is all-or-nothing.
Downgrade. Users change their minds about one permission without wanting to disconnect the integration entirely. Removing a single scope from an existing grant should be possible, and it should take effect on the next token refresh at the latest.
Revocation that actually revokes. Ship RFC 7009 token revocation, and make sure revoking a grant kills the refresh token and any outstanding access tokens. If your access tokens are stateless JWTs, revocation is bounded by their lifetime, which is a strong argument for keeping that lifetime short — the trade-off explored in JWTs vs opaque tokens. A "Connected apps" page that removes a row from a table while tokens keep working is worse than not having the page, because it tells the user they are safe when they are not.
Enforcing scopes without scattering the checks
The implementation failure mode is a scope check hand-written at the top of each handler. It works until someone adds an endpoint and forgets, and the forgotten one is invariably the interesting one.
Declare the requirement next to the route, and enforce it in one place:
router.get('/v1/contacts', requireScope('contacts:read'), listContacts);
router.post('/v1/contacts', requireScope('contacts:write'), createContact);
router.delete('/v1/contacts/:id', requireScope('contacts:write'), deleteContact);
Then add the test that actually protects you: enumerate every registered route and assert that each one declares a scope. A route with no declared scope fails the build. That single test catches the entire class of "we shipped an endpoint that ignores scopes," which is otherwise found by a security researcher.
Log the scope of every authorized request. When you eventually need to deprecate a scope, usage data is the only way to know who breaks.
Key takeaways
- Scopes narrow a token; they never widen a user's permissions. Check both, always, in the same place.
- Granularity rule: the smallest unit a real integration would request, and no smaller. Read/write is the highest-value split.
resource:action, consistently named, no identifiers in scope strings, no renames after publication.- Incremental authorization — ask in context, at the moment of the action — is what makes consent meaningful.
- Consent copy describes consequences, ordered by risk, with the app's verification status and a revocation link.
- Build partial approval, downgrade, and real revocation. Return the granted scope set, and name the missing scope in your 403s.
- Enforce declaratively, and fail the build on any route without a declared scope.
Written by
Emilian GheoneaSenior Blockchain & Full-Stack Software Engineer. I build EmbedAuth — an embeddable authentication platform for SaaS — and write about the auth problems most teams hit too late.
Related articles
OAuth & OIDC
OAuth Token Exchange: Delegation, Impersonation, and Service-to-Service Auth
RFC 8693 explained — how token exchange solves the confused deputy problem in microservices, the difference between delegation and impersonation, act and may_act claims, and when a simpler answer is better.
Jul 23, 2026 7 minReadOAuth & OIDC
OAuth Popup vs Redirect: Which Flow Should You Use?
A practical comparison of popup-based and full-redirect OAuth flows — UX, state preservation, mobile and browser quirks, security trade-offs, and how each behaves inside an embedded iframe.
Jun 27, 2026 6 minReadOAuth & OIDC
Every Social Login Provider Is Slightly Wrong (Here's How)
Google, Apple, GitHub, Microsoft, Facebook and LinkedIn all implement OAuth differently enough to break your assumptions. A field guide to the deviations that cost real debugging time, and the abstraction that survives them.
Jul 21, 2026 8 minReadOAuth & OIDC
SAML vs OIDC: Which SSO Standard Should You Support?
A clear comparison of SAML and OpenID Connect for single sign-on — how each works, XML vs JSON, where they fit, and what B2B SaaS teams should support for enterprise customers.
Jul 3, 2026 6 minRead