How-to › Authenticate and authorize calls

How to enforce scopes at the resource server, not in the client#

Turn the scope table in your OpenAPI document into checks on every request, prove them with a negative test suite, and answer refusals as 403 problems.

Audience
API producer
Level
advanced
Topic
Design an authentication scheme for your API
Languages
TypeScript and JavaScript
Verified

Your API’s SDK refuses a call the caller’s scopes do not cover, and the dashboard hides the button. Then a customer scripts the same request with curl, an orders:read token, and another tenant’s order id, and the row comes back. Every check you have runs in code the caller can edit or skip.

What you get

You will end up with scope checks inside the API, read from the OpenAPI document. A 403 names the missing scope, and a test suite fails if a route escapes. This is for you if a permission check lives in a client.

Short answer

Read each operation’s scopes from the OpenAPI document and register every route behind two Express middleware functions. The first verifies the token’s signature, issuer, audience, expiry, and typ. The second compares the operation’s scopes with the token’s scope claim and answers 403 as an RFC 9457 problem naming the missing scope. Then filter the row by the token’s tenant, because a scope says what and the tenant says whose. A check in the client only saves a round trip.

You will need

Node 22 or later, an OpenAPI 3 document whose operations carry scopes in their security requirements, and an authorization server that issues JWT access tokens with a scope claim. Verified 2026-10-21 against Node 22.22.0, Express 5.3.0, jose 6.2.12, and Open Policy Agent 1.21.2. The sample’s issuer is a stand-in in issuer.mjs that mints tokens in the RFC 9068 profile, so every command runs offline. The API verifies tokens with jose and answers every refusal as an RFC 9457 problem.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
CerbosRules per resource kind in YAML, with roles and conditions, answered by a decision point your code callsA second service to run and version, a call to it per request, and roles the token must map ontoThe decision is a scope comparison and a tenant match that twelve lines of middleware state
Envoy ext_authzA proxy already in front of the API, and a decision that needs only the request line and headersAn authorization service to run, a 403 with no body of yours, and a request body forwarded only when configuredThe decision needs the row being accessed, which lives behind the proxy
Express middlewareA Node API where the check sits beside the route it protects and reads the row it is about to returnA middleware on every route, scattered across handlers unless the routes are registered from one tableSeveral services in several languages need one copy of the rules
Kong OpenID Connect pluginA Kong gateway, where scopes_required refuses a token before the upstream sees the requestAn Enterprise-tier plugin, and a check that knows the route and the claims but never the rowYou run the open source gateway, or the check needs the object
Open Policy AgentRules shared by several services, written once in Rego and queried over HTTP with the request as inputA policy language to learn and test, a sidecar to run, and a hop on every requestOne service in one language owns the rules
sdkgen rbac featureA generated client that should refuse a call it knows will fail before any request leavesA rule table that duplicates the server’s and protects nothing, since a caller can build the client without itWhenever the check has to be the boundary, which is every time

Voxgig maintains sdkgen. This page compares its rbac feature with Express middleware, Open Policy Agent, Cerbos, Kong, and Envoy.

The five server-side rows differ in what the check can see. Middleware runs in the handler’s process and can read the row it is about to return, which the tenant check needs. Cerbos and Open Policy Agent centralize the rules and see whatever the API sends them, at the price of a hop and a second language. A gateway sees the route and the token and never the row. The sdkgen rbac feature sees only the client’s own configuration, so it saves a round trip without deciding anything.

Derive the scope table from the document

The document already says which operation needs which scope. An operation’s security requirement lists them, and an operation without one inherits the global requirement. scopeTable reads both and turns {orderId} into the :orderId an Express route wants. Only an empty security list makes an operation public, so GET /me, which names no scope, still needs a token. OpenAPI reads several requirements as alternatives, so the table refuses them.

export function scopeTable(doc) {
  const rows = []
  for (const [path, item] of Object.entries(doc.paths)) {
    for (const [method, op] of Object.entries(item)) {
      if (!METHODS.test(method)) continue
      const security = op.security ?? doc.security ?? []
      // OpenAPI reads the list as alternatives, any one of which will do. One
      // requirement per operation is what this table holds, so more is refused.
      if (security.length > 1) {
        throw new Error(`${method.toUpperCase()} ${path} lists ${security.length} alternative security requirements; scopeTable handles one`)
      }
      const [requirement = {}] = security
      rows.push({
        method: method.toUpperCase(),
        path,
        route: path.replace(/\{(\w+)\}/g, ':$1'),
        operationId: op.operationId,
        // No requirement, or an empty one, is public. A requirement that lists
        // no scopes still needs a valid token.
        public: 0 === Object.keys(requirement).length,
        scopes: [...new Set(Object.values(requirement).flat())],
      })
    }
  }
  return rows
}
node scopes.mjs
GET     /health             health        (public)
GET     /me                 whoami        (any valid token)
GET     /orders             list_orders   orders:read
POST    /orders             create_order  orders:write
GET     /orders/{orderId}   get_order     orders:read
DELETE  /orders/{orderId}   delete_order  orders:admin

That table is the only place scopes are written down. The server registers its routes from it, so a route cannot exist without the scopes the document gives it.

Verify the token, then the scope, then the row

The three checks run in order; a refusal at any of them is a problem response. The first is the token itself. RFC 9068 says a resource server must check that the typ header is at+jwt, that iss matches exactly, and that aud contains its own identifier. It must verify the signature with the issuer’s keys, check that exp lies ahead, and refuse alg: none. One jwtVerify call does all of that from a JWKS.

let payload
try {
  // Signature, exp, iss and aud are checked here; typ pins the RFC 9068
  // profile, so an ID token presented as an access token is refused.
  ;({ payload } = await jwtVerify(m[1], keys, { issuer, audience, typ: 'at+jwt' }))
} catch (err) {
  return invalidToken(res, reason(err))
}
if (isRevoked(payload.jti)) return invalidToken(res, 'The token has been revoked.')

req.auth = {
  sub: payload.sub,
  tenant: payload.tenant,
  scopes: new Set(String(payload.scope ?? '').split(' ').filter(Boolean)),
}
next()

RFC 6750 fixes the vocabulary of the refusals. invalid_token with a 401 is for a token that fails any of those checks, and insufficient_scope with a 403 is for a token that passes them and lacks a scope. A request that sent no credentials at all gets a bare challenge with no error code.1 The scope claim is the space-delimited string RFC 8693 defines, so the middleware splits it into a set.

export function requireScopes(scopes, { method, path }) {
  return function requireScopesFor(req, res, next) {
    const missing = scopes.filter((s) => !req.auth.scopes.has(s))
    if (!missing.length) return next()
    problem(res, 403, {
      type: PROBLEMS + 'insufficient-scope',
      title: 'Insufficient scope',
      detail: `${method} ${path} requires the scope ${missing.join(' ')}, which the token does not carry.`,
      instance: req.originalUrl,
      missing_scope: missing,
    }, {
      'www-authenticate': `Bearer error="insufficient_scope", scope="${scopes.join(' ')}"`,
    })
  }
}

The 403 body is a problem detail. type, title, status, detail, and instance are the members RFC 9457 defines, and missing_scope is an extension member, which the RFC says a client must ignore when it does not recognize it.2 The WWW-Authenticate header repeats the required scope in the scope attribute the same RFC 6750 section allows.

The third check is the row. orders:read says the caller may read orders, and the token’s tenant claim says whose. Every read goes through visible, and a hit in another tenant is a 404 rather than a 403, so the response does not confirm that the id exists.

const visible = (req) => (objectCheck ? ORDERS.filter((o) => o.tenant === req.auth.tenant) : ORDERS)

const handlers = {
  health: (req, res) => res.json({ ok: true }),
  whoami: (req, res) => res.json({ sub: req.auth.sub, tenant: req.auth.tenant }),
  list_orders: (req, res) => res.json(visible(req)),
  get_order: (req, res) => {
    const order = visible(req).find((o) => o.id === req.params.orderId)
    return order ? res.json(order) : problem(res, 404, { type: 'about:blank', title: 'Not Found' })
  },
  create_order: (req, res) => res.status(201).json({ id: 'ord_3', tenant: req.auth.tenant, ...req.body, status: 'open' }),
  delete_order: (req, res) => res.status(204).end(),
}

for (const op of scopeTable(doc)) {
  const guards = op.public ? [] : [auth, requireScopes(op.scopes, op)]
  app[op.method.toLowerCase()](op.route, ...guards, handlers[op.operationId])
}

The loop at the end is the enforcement: a handler is reachable only through the guards the table assigns it, and Hono’s middleware takes the same shape.

Run the negative suite

One request per way a token can be wrong, against the running API.

node demo.mjs
GET   /orders         orders:read, right audience      200                                                           
POST  /orders         orders:read, needs orders:write  403  Bearer error="insufficient_scope", scope="orders:write"  POST /orders requires the scope orders:write, which the token does not carry.
GET   /orders         minted for another audience      401  Bearer error="invalid_token"                             The token's aud claim is not for this API.
GET   /orders         expired a minute ago             401  Bearer error="invalid_token"                             The token has expired.
GET   /orders         revoked by jti                   401  Bearer error="invalid_token"                             The token has been revoked.
GET   /orders         an ID token, typ JWT             401  Bearer error="invalid_token"                             The token is not an access token.
GET   /orders/ord_9   another tenant's order           404                                                           
GET   /orders         no token                         401  Bearer realm="fulfil"                                    
GET   /health         no token, public operation       200                                                           

the 403 body, application/problem+json; charset=utf-8
{
  "status": 403,
  "type": "https://api.example.com/problems/insufficient-scope",
  "title": "Insufficient scope",
  "detail": "POST /orders requires the scope orders:write, which the token does not carry.",
  "instance": "/orders",
  "missing_scope": [
    "orders:write"
  ]
}

Four refusals are 401 invalid_token, the ID token among them, which fails on typ alone.3 One is a 403 that names the scope in the header and in the body. The other tenant’s order is a 404, the empty challenge carries no error code, and /health needs no token because its security is an empty list.

Write the same rule for a policy engine

A policy engine takes the decision out of the handler and into a document several services share. With Open Policy Agent the API posts an input document to the agent’s data API and reads the decision back. The policy below takes the method, the path, the token’s scopes and tenant, and the row’s tenant, with the scope table loaded as data.

package fulfil.authz

default allow := false

# Public operations have an empty security list and need no token.
allow if operation.public

# Every other operation needs a token, its scopes, and the row's tenant.
allow if {
	operation.public == false
	every scope in operation.scopes {
		scope in input.token.scope
	}
	input.resource.tenant == input.token.tenant
}

operation := data.operations[input.method][input.path]

Cerbos writes the same shape as a YAML resource policy: rules with actions, an effect, roles, and a condition, evaluated by a decision point your code calls through a client library. The handler still has to send the row’s tenant, because an engine cannot see a row it was not sent. The gateway options cannot see it at all. Kong’s plugin compares scopes_required against a claim, Envoy’s filter asks an external service with the request line and headers, and the row is behind both.

Check it worked

Thirteen tests run, eleven of them over HTTP. The one to keep is table-driven. A token with no scopes goes to every operation that names a scope, and each must answer 403 with exactly the scopes the table holds.

test('every operation that names a scope refuses a token with no scopes', async () => {
  const { token } = await issuer.mint({ scope: '' })
  for (const op of scopeTable(doc).filter((op) => op.scopes.length)) {
    const r = await call(op.method, op.path.replace('{orderId}', 'ord_1'), token, 'POST' === op.method ? {} : undefined)
    assert.equal(r.status, 403, `${op.method} ${op.path}`)
    assert.deepEqual(r.body.missing_scope, op.scopes, `${op.method} ${op.path}`)
  }
})
node --test authz.test.mjs
1..13
# tests 13
# suites 0
# pass 13
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 474.09377

When it goes wrong

A valid orders:read token reads another tenant’s order. The scope check passed because the scope is right, and the handler looked the order up by id alone.

node pitfall.mjs
scope checked on the route, tenant not checked
  GET /orders/ord_9 with an acme token   200  tenant globex
  GET /orders with an acme token         200  3 orders, tenants acme globex
scope and tenant checked
  GET /orders/ord_9 with an acme token   404  Not Found
  GET /orders with an acme token         200  2 orders, tenants acme

The first build is the route-only check. OWASP lists it first in its API Security Top 10 as broken object level authorization, and a scope test suite cannot find it, because every scope is right. The fix is the tenant filter on every read and a cross-tenant test beside the scope tests.

The 401 and the 403 swap places. A missing scope answered with 401 sends the client to refresh a token that was fine, and an expired token answered with 403 tells it to stop trying. RFC 6750 assigns invalid_token to 401 and insufficient_scope to 403; keep them apart.

The client’s own check is treated as the boundary. sdkgen’s rbac feature denies a disallowed operation at PrePoint, before any request leaves, and its documentation says it is not a security boundary and does not replace server-side authorization. The same holds for a hand-written pre-flight check: it spares the caller a round trip on a request the server would refuse.

When not to do this

Do not move the check to the client to save the round trip. sdkgen’s rbac feature, a hidden button, and a pre-flight call all run in code the caller controls, and the server answers whatever arrives. Keep them for the user experience, and let the 403 be the boundary.

Do not scope by HTTP verb alone. A scope that grants GET /orders grants every field of every order the tenant filter lets through, so scope by the sensitivity of the resource and keep the tenant check beside it.

Do not put the object check in the gateway. Kong and Envoy see the route and the token, and the row is behind them, so a 403 from the edge proves the scope and not the ownership.

Do not return 403 for another tenant’s object. A 403 confirms that the id exists, and the 404 the sample returns tells the caller nothing it did not know.

Last verified

Verified 2026-10-21 against Node 22.22.0, Express 5.3.0, jose 6.2.12, and Open Policy Agent 1.21.2. Every output block is what the command preceding it printed. The issuer is the stand-in in issuer.mjs, on the loopback interface. policy.rego was evaluated with opa eval against the scope table and four inputs, outside the page’s commands, because the binary is not in the gate. Cerbos, Kong, and Envoy were not run; their behavior is read from the documentation each row links.

Footnotes

  1. Section 3.1 of RFC 6750 gives the resource server three error codes and a status for each: invalid_request with 400, invalid_token with 401, and insufficient_scope with 403, every one a SHOULD. Then it turns to the request that carried no credentials at all and says the server SHOULD NOT include an error code or other error information. A caller who sent nothing is told nothing, which is the one refusal in the section where the specification asks the server to say less. ↩︎ Back to text

  2. RFC 9457 is dated July 2023 and obsoletes RFC 7807 of March 2016. Its Appendix D lists what changed: a registry of common problem types, guidance on reporting several problems at once, and guidance on a type that cannot be dereferenced. The media type, application/problem+json, did not change, and section 3.2 still says clients consuming problem details MUST ignore any extensions they do not recognize, which is the sentence missing_scope relies on. ↩︎ Back to text

  3. Section 2.1 of RFC 9068 registers the media type application/at+jwt and says JWT access tokens MUST carry it in the typ header, with the application/ prefix omitted as RFC 7515 recommends. The stated purpose is to keep OpenID Connect ID Tokens from being accepted as access tokens by resource servers. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.