Scopes: Least Privilege as Contract Surface
A scope caps what a credential may ask for — projects:read cannot touch billing even if the user behind it can. Too coarse and every integration holds admin; too fine and nobody can predict which scope an endpoint needs. The catalog is the contract.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
What a scope is — and what it is not
A scope is a cap on the *credential*, not a grant to the *user*. When a CI integration holds a token scoped to projects:read, the API will refuse that token a billing read even though the human who authorized it is the org owner who could see billing in the dashboard. That is the point: effective access is the intersection of what the user may do and what the token was scoped to ask. Scopes exist because credentials leak, get logged, and sit in third-party databases — the scope decides what the thief gets.
This makes scopes a different axis from Authorization Design in the Contract, and you need both. Scope answers "may this *application* ask for this category of operation?" — checked first, cheap, no data required. User permission answers "may this *person* touch this *object*?" — checked second, against the data. A token with projects:write still cannot edit a project its user is not a member of; a project admin's token without projects:write cannot edit anything. Collapsing the two axes — treating a scope as if it granted access, or skipping object checks because "the scope covers it" — recreates Broken Access Control (IDOR / BOLA) with extra steps.
projects:read list/read projects and memberships
projects:write create/update projects, manage members
billing:read read invoices, usage, plan
billing:write change plan, payment methods
webhooks:manage register/rotate webhook endpoints
admin:org org settings, member removal (sensitive:
separate consent language, extra review)
rules: <resource>:<action> · read never implies write ·
write implies read of the same resource ·
every endpoint doc names its required scopeGranularity: the failure is at both ends
One giant scope is the common failure: the API offers api:full (or every integration requests admin because that is what the tutorial showed), and now the read-only Slack notifier holds a credential that can delete the org. When it leaks — and integrations' credentials leak, from *their* infrastructure, outside your control — the blast radius is everything. Users learn nothing from the consent screen ("this app wants: full access"), so consent stops being informed, and your audit team cannot distinguish the integration that could have exfiltrated billing data from the one that could not.
The opposite failure is quieter: per-endpoint or per-field scopes (projects.name:read, projects.members.email:read) produce a catalog of hundreds, consent screens with twelve checkboxes nobody reads, and developers who cannot predict which scope tomorrow's endpoint will need — so they request everything available, defeating the purpose with the opposite mechanism. The workable middle for most APIs: resource-family × read/write, single digits to a few dozen scopes, with read never implying write and genuinely dangerous capabilities (org deletion, member removal, secret access) carved into their own explicitly-named scopes so the consent screen says something a human can weigh.
1available scopes:2 admin — full API access3 4# the notifier that posts "build passed" to a channel:5requested: admin # no smaller option exists6granted: admin7 8# when the notifier's host is compromised, the token9# reads billing, exports members, deletes projects.10# consent screen said: "full access to your account"1available scopes:2 projects:read projects:write billing:read3 billing:write webhooks:manage admin:org4 5# the same notifier:6requested: projects:read7granted: projects:read8 9# compromised host yields read-only project metadata.10# consent screen said: "read your projects" — a claim11# the user could actually evaluateScope granularity is chosen at catalog-design time and paid at breach time. The good catalog keeps the notifier's theoretical damage proportional to its actual job, and makes the consent screen a real security control instead of a click-through.
Scopes in the request cycle, and how the catalog grows
Scopes appear in the contract at three moments. At request time, the consumer names them (the OAuth scope parameter — the flow itself is OAuth 2.x — Delegated Authorization territory; the *catalog* being requested is yours). At response time, the token comes back with the scopes actually granted, which may be fewer — contracts that let users deselect scopes must say so, and consumers must read the granted list instead of assuming. At failure time, a call outside the token's scope returns 403 with error.code: "insufficient_scope" and — the clause developers thank you for — *which scope was missing*, so the fix is a re-consent with the named scope rather than an afternoon of probing. Keep this failure distinct from 401 (credential broken, see Authentication in the Contract) and from permission denials: three different recoveries, three different error identities.
The catalog evolves under compatibility rules like any contract surface. Adding a scope for a new resource family is additive and routine — new endpoints require it, existing tokens that lack it get the documented insufficient_scope failure, and integrations opt in by re-requesting consent. What you cannot do quietly: retroactively *broaden* what an existing scope covers (tokens in the wild silently gain power — a security regression executed by documentation), or *narrow* one (integrations break mid-flight). Both are real changes to deployed credentials; narrow with a deprecation program, broaden never — mint a new scope instead.
- Request — consumer asks for named scopes; least privilege starts with the smallest set that covers the integration's features.
- Grant — the token carries granted scopes, possibly fewer than requested; consumers must read, not assume.
- Enforce — scope check first (cheap, no data), object-level authorization second (see Authorization Design in the Contract).
- Fail —
403+insufficient_scope+ the missing scope's name; recovery is re-consent, not debugging. - Evolve — new scopes are additive; broadening an existing scope silently arms deployed tokens — mint a new one instead.
Key points
- A scope caps the credential, not the user: effective access = user permissions ∩ token scopes, and the scope decides what a leaked token is worth.
- Scopes and object-level authorization are different axes — scope gates the operation category cheaply up front; the relationship check still guards each object.
- One giant admin scope makes every integration a maximum-blast-radius credential and turns consent screens into noise.
- Per-field micro-scopes backfire the same way: unpredictable catalogs drive developers to request everything.
- Resource-family × read/write, with dangerous capabilities carved out by name, is the granularity most APIs can document and users can evaluate.
- Fail with
insufficient_scopenaming the missing scope; add scopes freely, never silently broaden an existing one.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Provider → catalog: launches with one
adminscope "to keep the MVP simple"; every integration requests it because nothing else exists. - 2Users → consent: approve "full access" screens by reflex; consent stops carrying information.
- 3Integration → breach: a third-party notifier's server is compromised; its token — scoped to everything — exports the member list and billing history.
- 4Provider → retrofit: introduces granular scopes after the incident; thousands of live admin tokens persist because revoking them breaks every existing integration at once.
- 5Migration → years: the provider runs consent-nagging campaigns to re-scope old integrations, one partner at a time.
- Leaked integration credentials carry maximum privilege: the blast radius of any third-party compromise is your whole API surface.
- Users cannot give informed consent, and audits cannot bound which integration could have accessed what — every investigation starts from "assume everything".
- Retroactive scope-tightening breaks live integrations, so the over-broad grants persist precisely because fixing them is a breaking change.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Design the scope catalog with the API, not after it: resource-family × read/write, dangerous capabilities as separate named scopes, documented rules (write ⊃ read).
- • Print the required scope in every endpoint's documentation, and return `insufficient_scope` with the missing scope named on denial.
- • Enforce scope-then-object ordering in one middleware path so neither check can be individually forgotten.
- • Give sensitive scopes friction: distinct consent language, app review, shorter token lifetimes — the scope name is where policy attaches.
- • Distribution of granted scopes across integrations: a spike at the broadest scope means the catalog's granularity or the docs' examples are pushing developers there.
- • Per-token scope *usage* vs grant: a token holding `billing:write` that has only ever read projects is standing risk — surface it to the integration owner for narrowing.
- • `insufficient_scope` rates by endpoint show where the catalog confuses developers — chronic misses on one endpoint mean its scope assignment is unpredictable.
- • New resource families ship with new scopes; existing tokens fail closed with a named, documented error until re-consent — additive and safe.
- • Splitting a broad scope into narrower ones runs as a migration: new tokens get the narrow scopes, old tokens keep working with the broad one under a deprecation clock, consent prompts nudge re-authorization.
- • Never silently broaden a live scope's coverage; mint a new scope and let integrations opt in — deployed tokens gaining power by docs edit is a security regression.
- • A real scope catalog is upfront design work and permanent documentation surface — the single-admin-scope MVP genuinely ships faster and costs its savings at the first integration breach.
- • Scope checks add a small constant cost to every request and a second vocabulary (scopes *and* roles) that support and docs must keep distinct.
- • Finer scopes mean more re-consent moments as integrations grow features — each one is user friction, and each avoided one is standing over-privilege.