OAuth scopes look simple on the surface. You define a string like read:users, request it during authorization, and use it to gate API access. In practice, scope design is one of the most consequential decisions you'll make in your auth architecture — and one of the hardest to change once you've got production clients depending on it. Bad scope design leads to either over-permissioned tokens (security risk) or scope sprawl (developer experience nightmare). This post covers how to design scopes that scale.
Coarse vs fine-grained scopes
The first decision is granularity. Consider a document management API. You could design scopes at different levels:
Coarse: documents
Medium: documents:read, documents:write
Fine: documents:read, documents:create, documents:update, documents:delete, documents:share
Neither extreme is right. Coarse scopes are easy to implement but lead to over-permissioned tokens — an app that only needs to read documents has to request write access too. Ultra-fine scopes give precise control but create a consent dialog that no user will read, and API clients that need to request 15 scopes just to do basic operations.
A useful heuristic: model scopes around the consent story, not the API surface. A user can understand "this app wants to read your documents" — they cannot meaningfully understand the difference between documents:update:metadata and documents:update:content.
A practical taxonomy for SaaS APIs
# Convention: resource:action
# Read/write split at the resource level
# Admin tier gated separately
read:profile # read user profile, email
write:profile # update profile fields
read:billing # view invoices, subscription status
write:billing # change plan, update payment method
read:members # list org members
write:members # invite/remove members
admin:org # delete org, transfer ownership, SSO config
# Machine-to-machine scopes (client credentials flow)
service:webhooks # send webhooks on behalf of system
service:reports # generate reports, export data
Keep the total scope count under 20 for your initial v1. You can always add more; you cannot remove or rename without breaking clients.
Scope creep patterns to avoid
Scope creep happens when developers add new scopes on an ad-hoc basis without a coherent taxonomy. Watch for these anti-patterns:
- Feature-coupled scopes:
read:v2-dashboard,use:new-export-engine— these tie your auth system to feature flags and become meaningless when the feature is GA. - Overlapping scopes: If
adminimpliesread:membersandwrite:members, make that explicit in your documentation and enforcement logic. Don't silently merge them — clients will build incorrect assumptions. - Undocumented implicit scopes: Tokens issued during the authorization code flow might implicitly include
openidandoffline_access. If these aren't visible in your token introspection endpoint, clients can't reason about their permissions.
Resource indicators (RFC 8707)
Standard OAuth scopes have a critical limitation: they're global. A token with scope read:documents is valid against any resource server that accepts it. If your platform has multiple APIs — say, a document API and a billing API — a token issued for the document API could potentially be accepted by the billing API if both check for the same scope strings.
RFC 8707 adds a resource parameter to the authorization and token request that restricts which audience the token is valid for:
GET /authorize?
response_type=code&
client_id=app_123&
scope=read:documents&
resource=https://docs-api.bastionary.com&
redirect_uri=https://myapp.com/callback
The authorization server then binds the aud claim in the issued access token to the requested resource:
{
"sub": "user_abc",
"aud": "https://docs-api.bastionary.com",
"scope": "read:documents",
"exp": 1748000000,
"iat": 1747999100
}
Each resource server must validate that the aud claim matches its own identifier before accepting the token. This prevents token replay across services.
// Middleware for resource server validation
function requireAudience(expectedAudience: string) {
return (req: Request, res: Response, next: NextFunction) => {
const token = verifyJWT(req.headers.authorization?.split(' ')[1]);
const aud = Array.isArray(token.aud) ? token.aud : [token.aud];
if (!aud.includes(expectedAudience)) {
return res.status(401).json({ error: 'invalid_audience' });
}
next();
};
}
Audience restriction without RFC 8707
If your authorization server doesn't support RFC 8707, you can implement audience restriction manually. Issue separate client credentials for each resource server and validate the azp (authorized party) claim:
// When issuing tokens for machine-to-machine flows
function issueServiceToken(clientId: string, scopes: string[]): string {
const allowedResources = CLIENT_RESOURCE_MAP[clientId];
return signJWT({
sub: `client:${clientId}`,
azp: clientId,
aud: allowedResources, // explicit audience binding
scope: scopes.join(' '),
exp: Math.floor(Date.now() / 1000) + 3600,
});
}
Handling scope downgrades and upgrades
Real applications need to request additional scopes after the initial login — a user grants read access to their profile, then later connects a calendar integration that needs additional permissions. OAuth 2.0 handles this with incremental authorization: present the consent dialog again with only the new scopes:
// Request additional scopes incrementally
function buildIncrementalAuthUrl(
existingScopes: string[],
newScopes: string[],
state: string
): string {
const missingScopes = newScopes.filter(s => !existingScopes.includes(s));
if (missingScopes.length === 0) return ''; // already have them
return `${AUTH_SERVER}/authorize?` + new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
scope: missingScopes.join(' '),
// Include 'prompt=consent' to show dialog even if previously granted
prompt: 'consent',
state,
});
}
include_granted_scopes=true parameter to merge new scopes with previously granted ones into a single token — this avoids the fragmentation problem where you'd otherwise need to manage multiple tokens.
Scopes in the consent dialog
Your authorization UI must show users exactly what they're consenting to, in plain language. A scope like read:members should render as "Read the list of members in your organization" — not the raw scope string. Maintain a registry:
const SCOPE_DESCRIPTIONS: Record<string, { label: string; description: string; risk: 'low' | 'medium' | 'high' }> = {
'read:profile': { label: 'Read profile', description: 'View your name and email address', risk: 'low' },
'write:profile': { label: 'Edit profile', description: 'Update your profile information', risk: 'low' },
'read:billing': { label: 'View billing', description: 'View your invoices and subscription status', risk: 'medium' },
'write:billing': { label: 'Manage billing', description: 'Change your subscription and payment method', risk: 'high' },
'admin:org': { label: 'Org admin', description: 'Full administrative access to your organization', risk: 'high' },
};
Surface the risk level visually. High-risk scopes — billing writes, admin access — should require an extra confirmation click. This is where "scope inflation" starts to hurt users: if every OAuth app requests admin-level access because it's the easiest implementation path, users stop reading the consent dialog entirely.
Evolving your scope taxonomy
You will eventually need to rename or restructure scopes. The right approach:
- Introduce the new scope name while continuing to honor the old one.
- In your token validation logic, treat old scope as an alias for new.
- Update documentation, deprecate the old scope with a sunset date.
- After the sunset date, stop issuing new tokens with the old scope — but keep honoring it in validation for another 6–12 months.
- Only remove validation for the old scope after you're confident no production tokens carrying it are still in use (check your token analytics).
Never rename a scope and immediately stop honoring the old name. You will break production clients and earn a place in your customers' incident post-mortems.