Start with Identity
Recipe · Intermediate · 25 min

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.

OAuthAPINode.js
Before you start
  • 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: 401 with WWW-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.

Free to read and use. Code is illustrative, vendor-neutral, and built on the open standards. Review it against your provider's docs and your threat model before shipping. See more in the recipes index and the standards library.