# 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.

Source: https://voxgig.com/howto/enforce-scopes-at-the-resource-server

- Audience: api-producer
- Level: advanced
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-17

## 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](https://www.rfc-editor.org/rfc/rfc9068.html) profile, so every command runs offline.
The API verifies tokens with
[`jose`](https://github.com/panva/jose/blob/main/docs/jwt/verify/functions/jwtVerify.md) and
answers every refusal as an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) problem.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Cerbos](https://docs.cerbos.dev/cerbos/latest/policies/resource_policies) | Rules per resource kind in YAML, with roles and conditions, answered by a decision point your code calls | A second service to run and version, a call to it per request, and roles the token must map onto | The decision is a scope comparison and a tenant match that twelve lines of middleware state |
| [Envoy `ext_authz`](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter) | A proxy already in front of the API, and a decision that needs only the request line and headers | An authorization service to run, a 403 with no body of yours, and a request body forwarded only when configured | The decision needs the row being accessed, which lives behind the proxy |
| [Express middleware](https://expressjs.com/en/guide/using-middleware/) | A Node API where the check sits beside the route it protects and reads the row it is about to return | A middleware on every route, scattered across handlers unless the routes are registered from one table | Several services in several languages need one copy of the rules |
| [Kong OpenID Connect plugin](https://developer.konghq.com/plugins/openid-connect/) | A Kong gateway, where `scopes_required` refuses a token before the upstream sees the request | An Enterprise-tier plugin, and a check that knows the route and the claims but never the row | You run the open source gateway, or the check needs the object |
| [Open Policy Agent](https://www.openpolicyagent.org/docs/http-api-authorization) | Rules shared by several services, written once in Rego and queried over HTTP with the request as input | A policy language to learn and test, a sidecar to run, and a hop on every request | One service in one language owns the rules |
| [sdkgen rbac feature](https://github.com/voxgig/sdkgen/blob/main/docs/reference/features.md#rbac) | A generated client that should refuse a call it knows will fail before any request leaves | A rule table that duplicates the server's and protects nothing, since a caller can build the client without it | Whenever 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.

```js title="scopes.mjs"
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
}
```

```bash
node scopes.mjs
```

```text output
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](https://www.rfc-editor.org/rfc/rfc9068.html#section-4) 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.

```js title="authz.mjs"
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](https://www.rfc-editor.org/rfc/rfc6750.html#section-3.1) 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](https://www.rfc-editor.org/rfc/rfc8693.html#section-4.2)
defines, so the middleware splits it into a set.

```js title="authz.mjs"
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.

```js title="server.mjs"
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](https://hono.dev/docs/guides/middleware) takes the same shape.

## Run the negative suite

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

```bash
node demo.mjs
```

```text output
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.

```text title="policy.rego"
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.

```js title="authz.test.mjs"
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}`)
  }
})
```

```bash
node --test authz.test.mjs
```

```text output
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.

```bash
node pitfall.mjs
```

```text output
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](https://api-security.owasp.org/editions/2023/en/0xa1-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.

## Related how-tos

- [Test an API for broken object level authorization](/howto/test-for-broken-object-level-authorization)

- [Add auth at the gateway for a service system](/howto/add-auth-at-the-gateway-not-in-services)

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

- [Audit the scopes of the tokens your integrations use](/howto/audit-the-scopes-of-integration-tokens)

## 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.

[^1]: Section 3.1 of [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750.html#section-3.1) 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.

[^2]: [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html#appendix-D) 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.

[^3]: Section 2.1 of [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html#section-2.1)
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.