Scopes, Consent and Least Privilege
Designing permissions a user can understand and an API can enforce — plus the audience check that makes scopes meaningful.
A scope is a string in the token naming a permission the client was granted. It is the difference between "this app may read your calendar" and "this app is you".
Naming that survives
The convention that scales is resource:action:
orders:read orders:write orders:delete
invoices:read profile:read admin:allPredictable, greppable, and it maps one-to-one onto route middleware. Avoid vague scopes (full_access, user) — they cannot be explained on a consent screen and they cannot be tightened later without breaking clients.
Two ceilings, not one
This trips people up: scope is what the client may request; roles are what the user may do. Both apply, and the effective permission is the intersection.
- An admin using a third-party app that only requested
orders:readstill cannot delete orders through it. - A regular user with an app holding
admin:allstill cannot do admin things, because they are not an admin.
An API that checks scope but not the user's own permissions has a privilege escalation waiting.
Consent screens
Write scope descriptions for the person clicking Allow, not for your API docs. "Read your order history" beats "orders:read". Group them, mark which are optional, and request incrementally — ask for calendar access when the user first uses the calendar feature, not at signup. Approval rates rise and the token stays narrow for everyone who never touches that feature.
Scopes are worthless without aud
A scope only means something relative to an API. If your resource server does not check aud, a token bearing orders:read issued for a different service is accepted here — and now the scope name is the only thing standing between two unrelated systems.
Downscope on the way in
When one service calls another on a user's behalf, exchange the incoming token for one with only the scopes the downstream call needs (RFC 8693 token exchange). A compromised downstream service then cannot replay a token that could do more than the operation required.
Example
// Scope alone is not authorization — check the user too.
const requireScope = (scope) => (req, res, next) =>
req.user.scopes.includes(scope)
? next()
: res.status(403).json({ error: 'insufficient_scope', required: scope });
app.delete('/api/orders/:id',
bearerAuth, // who?
requireScope('orders:delete'), // may this CLIENT ask?
requireRole('admin'), // may this USER do it?
requireOwnership, // ...and is it theirs?
deleteOrder,
);When to use it
- An app requests calendar access only when the user first opens the calendar feature, so most users never grant it at all.
- A consent screen shows plain-language descriptions and marks two of five scopes optional, raising completion of the connect flow.
- A service downscopes an incoming token before calling a reporting service, so a compromise there cannot replay a token with write access.
More examples
A scope registry that drives everything
Keeping one registry means the consent screen can never drift from what the middleware enforces — a mismatch there is how users end up granting more than the screen described.
// One definition, used by the consent screen, the docs and the middleware.
export const SCOPES = {
'profile:read': { label: 'View your name and email', sensitive: false },
'orders:read': { label: 'View your order history', sensitive: false },
'orders:write': { label: 'Place and modify orders', sensitive: true },
'orders:delete': { label: 'Cancel and delete orders', sensitive: true },
'invoices:read': { label: 'View your invoices', sensitive: false },
'payments:write': { label: 'Charge your saved payment method', sensitive: true },
};
// Consent screen: plain language, sensitive ones highlighted
export function consentItems(requested) {
return requested
.filter((s) => SCOPES[s])
.map((s) => ({ scope: s, ...SCOPES[s] }))
.sort((a, b) => Number(b.sensitive) - Number(a.sensitive));
}
// Registration: refuse unknown scopes at the door rather than at request time
export function validateRequestedScopes(requested, client) {
const unknown = requested.filter((s) => !SCOPES[s]);
if (unknown.length) throw new Error(`unknown_scope: ${unknown.join(', ')}`);
const notPermitted = requested.filter((s) => !client.allowedScopes.includes(s));
if (notPermitted.length) throw new Error(`scope_not_allowed: ${notPermitted.join(', ')}`);
return requested;
}Incremental consent
Returning the scope needed and where to obtain it turns a dead-end 403 into a flow the client can complete without the user understanding what a scope is.
// Signup: ask for the minimum. Most users never need more.
startLogin({ scope: 'openid profile:read' });
// Later, when the user actually clicks "Connect calendar":
async function connectCalendar() {
startLogin({
scope: 'openid profile:read calendar:read',
// include_granted_scopes keeps the previously granted ones in the new token
// instead of replacing them — omitting it silently drops permissions.
include_granted_scopes: 'true',
prompt: 'consent',
});
}
// And on the API side, react to a missing scope in a way the client can use:
app.get('/api/calendar', bearerAuth, (req, res) => {
if (!req.user.scopes.includes('calendar:read')) {
return res.status(403).json({
error: 'insufficient_scope',
required: 'calendar:read',
// Enough information for the client to launch an incremental consent flow
authorize_url: '/auth/upgrade?scope=calendar:read',
});
}
res.json(calendarFor(req.user.id));
});
Discussion