# Protect an API with OAuth scopes

Source: https://startwithidentity.com/recipes/protect-an-api-with-oauth-scopes/
Last updated: 2026-06-19
License: content by Start with Identity. Cite the source URL.

---

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](https://startwithidentity.com/recipes/oidc-authorization-code-pkce/).

## 1. Extract the bearer token

Access tokens arrive in the `Authorization` header as defined by RFC 6750.

```ts
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](https://startwithidentity.com/recipes/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.

```ts
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.

```ts
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

```ts
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](https://startwithidentity.com/guides/authorization/) for the difference between scopes, roles, and policy-based access control.

Reference: [OAuth 2.1](https://startwithidentity.com/standards/oauth-2-1/), and RFC 6750 for bearer token usage.
