Scope-Based vs Role-Based API Authorization

Two models compete for the authorization decision in most API codebases, and teams frequently ship both without deciding which is authoritative. Token scopes travel with the request and are cheap to check. Server-side roles reflect current state and can express relationships a token cannot. Each fails in a characteristic way when used alone: scopes go stale and inflate, roles are invisible to the client and easy to forget in a new handler.

This guide compares the two honestly, then shows the composition that works in practice — the scope as a ceiling, the role as the decision — with the tests that prove both refusal paths. It is part of the Access Control & IDOR Prevention guide within Vulnerability Patterns & Web Mitigation Strategies, and assumes tokens are issued and validated as described in OAuth 2.0 and OpenID Connect implementation.

Prerequisites

  • An API that authenticates callers with bearer tokens, sessions, or both
  • A permission model with at least two roles that differ on some operation
  • A place to record which side of the decision refused a request — structured logs or traces
  • Test fixtures for a user whose role and scope deliberately disagree

Expected Outcomes

  • A documented rule for which model decides what, applied consistently across handlers
  • Roles resolved per request, so revocation takes effect immediately
  • Scopes treated as an upper bound that can only narrow the permitted set
  • Tests covering the two mixed cases, not just the fully-permitted and fully-denied ones

Step 1: Understand What Each Model Can and Cannot Express

The comparison below is the one worth internalising before writing any middleware.

Question Token scope Server-side role
Where does the answer live In the token, signed at issue time In your data store, read at request time
Cost per request Signature check only A lookup, usually cached
Reflects a revocation Only after the token expires On the very next request
Can express “this object” No — scopes are about operations Yes, including ownership and sharing
Visible to the client Yes, which aids good clients No, which prevents client-side assumptions
Typical failure Inflation: every client asks for everything Omission: a new handler never asks
Two Models, Two Characteristic Failures Token scopes live in a signed artefact, cost only a signature check, and cannot express object-level questions; they fail by inflating until every client holds every scope. Server-side roles live in the data store, reflect revocation immediately, and can express ownership; they fail by omission when a new handler never consults them. Token scope answer travels with the request, signed no lookup, so it scales trivially cannot say anything about a specific object stale until the token expires Fails by inflation every client requests every scope, because nobody wants to ship a permission bug Server-side role answer read from the store, per request one cached lookup, invalidated on change can express ownership, sharing, delegation revocation applies on the next request Fails by omission a new handler simply never calls the policy, and nothing in the request looks wrong

Step 2: Make the Scope a Ceiling and the Role the Decision

The composition rule is short enough to put in a code comment: the scope says what this credential may attempt; the role says whether this subject may do it to this object. An action proceeds only when both agree.

// authorize.ts — one function every handler calls.
export async function authorize(ctx: RequestContext, action: Action, resource: Resource) {
  // 1. Ceiling: what did the credential consent to at all?
  if (!ctx.token.scopes.includes(scopeFor(action))) {
    return deny('scope', `token lacks ${scopeFor(action)}`);
  }

  // 2. Decision: what may this subject do to THIS object, right now?
  const role = await roles.forSubject(ctx.subject.id, resource.tenantId); // cached, invalidated on change
  if (!capabilities[role]?.has(action)) {
    return deny('role', `${role} may not ${action}`);
  }

  // 3. Object: does the subject reach this resource at all?
  if (resource.tenantId !== ctx.subject.tenantId) {
    return deny('tenant', 'cross-tenant');
  }
  return allow();
}

Three details earn their place. The refusal carries which side refused, so a support engineer can tell a customer whether to reinstall the integration or ask an administrator for a role — without that, every 403 becomes a ticket. The role lookup is cached with explicit invalidation on membership change, not with a time-to-live, so revocation is immediate rather than eventually consistent. And the tenant comparison sits outside the role matrix entirely, because no role should ever be a route across a tenant boundary.


Step 3: Keep Scopes Narrow Enough to Mean Something

A scope set that every client requests in full carries no information. Two habits keep scopes meaningful:

# Scopes describe operations on resource families, not job titles.
scopes:
  invoices.read:   "Read invoices belonging to the authenticated organisation"
  invoices.write:  "Create and modify invoices"
  invoices.void:   "Void an issued invoice"          # separate: it is irreversible
  reports.read:    "Read aggregate reports"
# Anti-pattern: admin, full_access, manage_everything — these are roles wearing a scope's clothes.

Then hold the line at review time: a new client requesting invoices.write when it only ever reads should be asked to justify it, and the consent screen should show the difference in plain language. Irreversible operations deserve their own scope so that consent to edit is not silently consent to destroy.

Scope Names That Mean Something, and Names That Do Not Three scope-naming patterns. Resource-and-verb scopes describe one operation on one resource family and can be reasoned about on a consent screen. Job-title scopes describe a person rather than an operation and inflate immediately. Catch-all scopes carry no information at all and are indistinguishable from having no scopes. invoices.read, invoices.write, invoices.void — resource and verb a consent screen can explain each one, and a reviewer can question a request for it accountant, manager, operator — job titles wearing scope clothing these are roles; they belong server-side where they can change without re-consent admin, full_access, manage_everything — catch-alls every client requests them, so the scope check stops carrying any information Both Must Agree, and the Refusal Says Which Did Not A two-by-two matrix of scope granted or missing against role permitted or not. Only the cell where both permit results in an allow. The other three cells each carry a distinct refusal reason, which is what turns a support ticket into a one-line answer instead of an investigation. Role permits the action Role does not permit Scope granted Allow object check still applies Refuse: role "ask an administrator" Scope missing from the credential Refuse: scope "reconnect the integration" Refuse: both report the scope first

Verification

Test the two mixed cells, because the fully-allowed and fully-denied cases pass even in a broken implementation.

it('refuses a broad scope held by a weak role', async () => {
  const token = await issueToken({ user: readOnlyMember, scopes: ['invoices.write'] });
  const res = await api.patch(`/invoices/${ownInvoice.id}`).auth(token).send({ status: 'void' });
  expect(res.status).toBe(403);
  expect(res.body.reason).toBe('role');     // not 'scope' — the ceiling was fine
});

it('refuses a strong role holding a narrow scope', async () => {
  const token = await issueToken({ user: orgAdmin, scopes: ['invoices.read'] });
  const res = await api.patch(`/invoices/${ownInvoice.id}`).auth(token).send({ status: 'void' });
  expect(res.status).toBe(403);
  expect(res.body.reason).toBe('scope');
});

it('applies a role revocation on the next request', async () => {
  const token = await issueToken({ user: orgAdmin, scopes: ['invoices.write'] });
  await roles.remove(orgAdmin.id, tenant.id);          // no token change
  const res = await api.patch(`/invoices/${ownInvoice.id}`).auth(token).send({ status: 'void' });
  expect(res.status).toBe(403);                        // immediate, not at token expiry
});

The third test is the one that catches a role claim smuggled into the token: if it passes only after the token expires, authority is living in the wrong place.


Troubleshooting

Symptom Likely cause Fix
Removed users keep access for minutes Role resolved from a token claim rather than the store Resolve per request and cache with explicit invalidation on membership change
Every client holds every scope Scopes named after job titles rather than operations Rename to resource-and-verb scopes and re-issue client registrations
Support cannot tell why a call was refused The refusal does not record which check failed Return and log a machine-readable reason field for each denial
Role cache causes intermittent failures Time-to-live expiry rather than event-driven invalidation Invalidate on the membership-change event; keep the time-to-live only as a safety net
A partner integration can exceed the user’s authority The role check was skipped when a token was present Run both checks on every path; a token is not a bypass

Common Implementation Mistakes


Frequently Asked Questions

Why not put roles directly in the access token?

Because a token is a snapshot and permissions are current state. A role claim minted at login remains valid until the token expires, so a user removed from a project at 10:00 keeps their access until the token lapses. Short lifetimes shrink the window without closing it, and shortening them enough to matter turns every request into a refresh round trip. Put a stable subject identifier in the token, resolve authority server-side, and revocation lands on the very next request.

Is a scope ever sufficient on its own?

For machine clients with no per-object dimension, yes — a service reading published exchange rates, where any caller with the scope may see everything the endpoint returns. The moment the response depends on which records the caller owns, the scope can only bound the operation, and something server-side has to answer the object question. Relying on scope alone in a user-facing API is one of the most common routes to shipping an object-level authorization failure.

How do delegated integrations fit this model?

A third-party integration acting on a user’s behalf holds a token whose scope encodes the user’s consent, while the user’s own role still bounds what can be done. Intersecting both is what makes consent meaningful: the integration cannot exceed the granted scope, and it cannot exceed the authority of the person who installed it. Without the intersection, a read-only collaborator becomes a writer simply by installing an application that asked for write scope — a privilege escalation with a consent screen in front of it.