Protecting an API: scopes and claims

OAuth, OIDC & JWT · Practical · 12 min

A valid token only says who is calling; your API still has to decide what they may do.

After validation, the API holds trustworthy claims. Authorization happens in layers:

  • Authentication middleware validates the token once per request and attaches the principal (sub, client id, scopes, roles) to the request context.
  • Coarse checks per route: does the token carry the scope this operation needs? GET /orders needs orders.read, POST /orders needs orders.write. Return 403 with error="insufficient_scope" when the token is valid but lacks a scope, and 401 when the token is missing or invalid.
  • Fine-grained checks per resource: scopes say what the client may do on the user's behalf, not what the user may do. The API must still check that order 123 belongs to sub or that the user's role allows the action. Skipping this produces broken object-level authorization (the top API vulnerability in the OWASP API Security Top 10).

Practical guidance:

  • Keep scopes coarse and stable; model detailed permissions in your own database or a policy engine, not as hundreds of scopes.
  • Distinguish user tokens from service tokens (client credentials have no user sub in the usual sense).
  • Log the sub, client id and decision for audits, never the token itself.

Example

// Express-style middleware (framework-agnostic idea)
function requireScope(scope) {
  return (req, res, next) => {
    const granted = (req.auth?.scope ?? "").split(" ");
    if (!req.auth) return res.status(401).set("WWW-Authenticate", 'Bearer').end();
    if (!granted.includes(scope)) {
      return res.status(403)
        .set("WWW-Authenticate", `Bearer error="insufficient_scope", scope="${scope}"`).end();
    }
    next();
  };
}

// Route: scope check AND ownership check
// app.get("/orders/:id", requireScope("orders.read"), async (req, res) => {
//   const order = await db.orders.find(req.params.id);
//   if (!order || order.ownerSub !== req.auth.sub) return res.status(404).end();
//   res.json(order);
// });
console.log(typeof requireScope("orders.read")); // function

Remember

  • 401 = no or invalid token; 403 = valid token, not allowed
  • Scopes limit the client; resource checks limit the user
  • Always verify the caller owns or may access the specific object

Watch out for

  • Trusting scopes alone and letting any user with orders.read read everyone's orders.

Check yourself

  1. A token is valid but lacks the `orders.write` scope. Which status should the API return?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.rfc-editor.org/rfc/rfc6750#section-3.1

Builds on

Unlocks

Nothing builds on this yet.