Protect an API with OAuth scopes
Turn any HTTP API into an OAuth 2.1 resource server: validate the bearer access token, enforce scopes per route, and return correct error responses.
- An authorization server issuing JWT access tokens
- Your API's audience/identifier
- Node.js with Express (the pattern maps to any framework)
A resource server is any API that accepts OAuth access tokens and serves protected data. Its job is narrow: validate the token, enforce the scopes the route requires, and answer with the right error when either check fails. This recipe builds that as Express middleware, but the logic maps to any framework. Clients obtain tokens via the authorization code flow.
1. Extract the bearer token
Access tokens arrive in the Authorization header as defined by RFC 6750.
function getBearer(req): string | null {
const h = req.headers.authorization ?? '';
const [scheme, token] = h.split(' ');
return scheme === 'Bearer' && token ? token : null;
}
2. Validate the token
If the authorization server issues JWT access tokens, verify them locally against its JWKS, exactly as in validate a JWT. The critical check for a resource server is audience: the token's aud must name your API, or you would accept tokens minted for a different service.
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL(process.env.JWKS_URI!));
async function verify(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: process.env.OAUTH_ISSUER!,
audience: process.env.API_AUDIENCE!, // this API's identifier
algorithms: ['RS256'],
});
return payload;
}
If the server issues opaque tokens instead, validate them with token introspection (RFC 7662) and cache the result for a few seconds.
3. Enforce scopes per route
Scopes are space-delimited in the scope claim. Write middleware that requires a specific scope, and return 403 with an insufficient_scope error when it is missing.
function requireScope(scope: string) {
return async (req, res, next) => {
const token = getBearer(req);
if (!token) {
return res
.status(401)
.set('WWW-Authenticate', 'Bearer error="invalid_token"')
.json({ error: 'invalid_token' });
}
try {
const claims = await verify(token);
const scopes = String(claims.scope ?? '').split(' ');
if (!scopes.includes(scope)) {
return res
.status(403)
.set('WWW-Authenticate',
`Bearer error="insufficient_scope" scope="${scope}"`)
.json({ error: 'insufficient_scope' });
}
req.auth = claims;
next();
} catch {
return res
.status(401)
.set('WWW-Authenticate', 'Bearer error="invalid_token"')
.json({ error: 'invalid_token' });
}
};
}
4. Apply it
app.get('/reports', requireScope('read:reports'), (req, res) => {
res.json({ owner: req.auth.sub, reports: [/* ... */] });
});
app.post('/reports', requireScope('write:reports'), (req, res) => {
// create a report
});
Get the error responses right
RFC 6750 is specific about resource server errors, and good clients depend on them:
- No or malformed token:
401withWWW-Authenticate: Bearer error="invalid_token". - Expired or invalid signature:
401 invalid_token. - Valid token, missing scope:
403 insufficient_scope, naming the required scope.
Beyond scopes
Scopes answer "what is this token allowed to do" coarsely. For fine-grained, per-resource decisions (can user X edit document Y), pair the token with an authorization layer. See the authorization guide for the difference between scopes, roles, and policy-based access control.
Reference: OAuth 2.1, and RFC 6750 for bearer token usage.