Designing OAuth scopes that don't come back to haunt you

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 admin implies read:members and write: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 openid and offline_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,
  });
}
Google's incremental authorization model is worth studying. They use the 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:

  1. Introduce the new scope name while continuing to honor the old one.
  2. In your token validation logic, treat old scope as an alias for new.
  3. Update documentation, deprecate the old scope with a sunset date.
  4. After the sunset date, stop issuing new tokens with the old scope — but keep honoring it in validation for another 6–12 months.
  5. 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.