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 |
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.
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.
Related
- Access Control & IDOR Prevention — the parent guide covering object-level enforcement end to end
- Preventing IDOR in REST APIs with Object-Scoped Queries — the object half of the decision this page bounds
- OAuth 2.0 & OpenID Connect Implementation — where scopes are requested, consented to and issued
- Validating OIDC ID Tokens in Node.js — verifying the credential before any of these checks run