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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Cerbos | 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 | 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 | 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 | 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 | 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 | 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.
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.
Related how-tos
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
-
Section 3.1 of RFC 6750 gives the resource server three error codes and a status for each:
invalid_requestwith 400,invalid_tokenwith 401, andinsufficient_scopewith 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 -
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
typethat 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 sentencemissing_scoperelies on. ↩︎ Back to text -
Section 2.1 of RFC 9068 registers the media type
application/at+jwtand says JWT access tokens MUST carry it in thetypheader, with theapplication/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