Your API issues access tokens to several kinds of client: an interactive app, an unattended batch job, a kiosk. They should not all be able to do the same things. The batch job should never delete. The kiosk should only read. Where is that limit enforced, and how do you tighten it later without breaking anything?
I tried four designs against a small issuer and three versions of a verifier. One of them looked the most natural and had a hole that the JWT standard itself creates.
TL;DR
- RFC 7519 section 4 says claim names a verifier does not understand “MUST be ignored” (unless other rules apply). That is the right default for extensibility. It also means a new restricting claim is invisible to every old verifier.
- In my test, a token with
scope: "notes:read notes:write"and a new claimro: truewas read-only for a verifier that knewroand read-write for one that did not. Nothing failed. - The safe rule: an ignored claim must only be able to give less. Express restrictions by changing a value every verifier already enforces (here
scope), computed at the issuer, and keep new claims for things whose absence is harmless. - Clamp at the issuer:
scope = requested ∩ ceiling[kind]. Never trust what the client asks for, and never rely on a UI that hides a button. - For a verifier that does read the client kind, make the missing or unknown case fail closed: a token without a
kindgets the lowest ceiling, an unknownkindis rejected. - The
critheader gives a “must understand or refuse” switch, but only for JOSE header parameters, not payload claims.
The setup
The issuer knows each client’s kind and holds a ceiling table:
const CEILING = {
interactive: ['notes:read', 'notes:write', 'notes:delete'],
kiosk: ['notes:read'],
batch: ['notes:read', 'notes:write'],
};
Tokens are JWTs signed with HS256 (a demo key, in the lab only), with typ: at+jwt. The “verifiers” are three small functions that stand for versions of a resource server over time. I built them with the jose library, 6.2.12. Everything below ran with Node.js 22.23.3.
Option 1: trust what the client asks for
The client sends the scopes it wants and the issuer signs them. This fails the moment a batch client asks for notes:delete, and nothing in the protocol prevents it from asking.
The fix is the first rule of the final design, and the code is one line:
export function grant(requested, kind) {
const ceiling = CEILING[kind];
if (!ceiling) throw new Error(`unknown client kind: ${kind}`);
return requested.filter((s) => ceiling.includes(s));
}
With it, a batch client asking for all three scopes gets two, and an unknown kind is an error, not a default. Rejected as stated; kept as a base layer.
Option 2: add a new restricting claim
Later you want a read-only mode for some tokens. The tidy way is a new claim: ro: true. A verifier that understands it drops every non-read scope.
// v2: knows the new claim
if (payload.ro === true) scopes = new Set([...scopes].filter((s) => s.endsWith(':read')));
The trouble is the verifier that does not know it. Version 1 of the resource server reads scope and nothing else, as the standard tells it to. I issued a token with both scopes and ro: true, and checked both verifiers:
v2 (knows ro): {notes:read}
v1 (ignores ro): {notes:read, notes:write}
Nothing broke, nothing logged, and the “read-only” token could write. Any deployment where a resource server is updated later than the issuer, or where a library or a second service verifies the same tokens, has this window. Rejected: it fails open.
Option 3: make old verifiers refuse with crit
JOSE has a mechanism for exactly this kind of change. The crit header parameter (RFC 7515 section 4.1.11) lists extensions that the recipient must understand; if it does not, it must reject the token. I signed a token with a ceil header parameter listed in crit:
old verifier: Extension Header Parameter "ceil" is not recognized (rejected)
aware verifier, told {crit: {ceil: true}}: accepted
That is the behavior you want, and it needs only one line to opt in on each side. Two limits make it a partial answer:
- It covers header parameters. A restriction in a payload claim cannot be marked critical, because
critdoes not apply to claims. In the lab the restriction had to live in the header. - Both sides must use libraries that implement
critcorrectly. jose does. I did not check others, and a library that skips the check puts you back in option 2.
Useful for rare, hard-to-ignore changes; not my default.
Option 4: make the verifier satisfy every restriction
The macaroons design (Birgisson et al., NDSS 2014) turns the default around. A token carries a list of conditions, called caveats, and verification means that every caveat must hold in the context of the request; a verifier that cannot evaluate one fails. This makes restriction safe to add at any time. It also means a different token format, a different library, and a different way to think about tokens, so I did not build it or run a macaroons library. Right design for delegation chains; too much change for the problem here.
The design I would pick
Combine what worked in the tests:
- Clamp at the issuer with
grant(). The token’sscopecan never exceed the ceiling for the client’s kind. - Restrict by narrowing a claim every verifier already enforces. When “read-only” is needed, issue a token whose
scopeisnotes:read. Old verifiers enforce it without knowing why. I confirmed all three verifiers return{notes:read}. - Carry the kind in a claim for the verifiers that use it, and let those verifiers fail closed. The strict verifier treats a token without
kindas the most restricted kind, rejects an unknown kind, and re-applies the ceiling to the scopes it reads:
export async function verifyStrict(jwt) {
const { payload } = await jwtVerify(jwt, key, { algorithms: ['HS256'], typ: 'at+jwt' });
const kind = payload.kind ?? MOST_RESTRICTED; // legacy token without a kind
if (!CEILING[kind]) throw new Error(`unknown kind: ${kind}`); // do not guess
const scopes = String(payload.scope ?? '').split(' ').filter(Boolean);
return new Set(scopes.filter((s) => CEILING[kind].includes(s))); // belt and braces
}
- Use explicit typing. The verifier requires
typ: at+jwt(the value RFC 9068 uses for JWT access tokens), so a token minted for another purpose and signed with the same key is rejected. This is RFC 8725 section 3.11. I tested that a token with anothertypfails.
The last test is the one that states the rule most plainly. I added an unknown granting claim to a token. V1 ignored it, and the result was the same set of scopes it would have had without the claim. Ignoring a claim that adds is harmless. Ignoring a claim that removes is not.
When an issuer ceiling is enough, and when it is not
Use an issuer-side ceiling when several client kinds share an authorization server and differ in how much they may hold, or whenever restrictions must be tightened after the fact. It is cheap, and the enforcement point is one function.
It does not solve per-request attenuation by the token holder (a client narrowing its own token before handing it on). That is what macaroon-style tokens are for. It also does not replace checks on the data itself; a scope says which kinds of operation are allowed, not which records.
Run the ceiling tests
mkdir ceiling && cd ceiling && npm init -y >/dev/null && npm i [email protected]
# put issuer.mjs and issuer.test.mjs from the lab here
node --test issuer.test.mjs
The seven tests are: the ro hole; narrowing scope; clamping a greedy client; crit refusal and the header-only limit; explicit typing; legacy and unknown kinds; and the harmless unknown granting claim. To feel the hole directly, delete the ro check from verifyV2 and watch the two verifiers agree on read-write.
Ways a claim ceiling breaks
- A restriction as a new claim. Symptom: a restricted token can still write on services that were not updated, with no error. Fix: narrow
scopeinstead, and treat any new claim as one whose absence must be safe. - Clamping only in the client. Symptom: a modified client or a direct call asks for more and gets it. Fix: intersect with the ceiling at the issuer on every issuance, including refresh.
- Old tokens without the new claim treated as the most privileged. Symptom: after you introduce
kind, tokens issued before the change are the most powerful ones. Fix: the missing case maps to the lowest ceiling, and tokens are short-lived so it fades out. - Defaulting an unknown kind. Symptom: a typo or a new client kind quietly receives the interactive set. Fix: throw on an unknown kind at the issuer and the verifier.
- Assuming
critprotects payload claims. Symptom: a team marks a claim “critical” and believes old verifiers will reject it. Fix: usecritonly for header parameters, and test the old verifier explicitly. - Accepting any JWT signed by the key. Symptom: an ID token or another service’s token is accepted as an access token. Fix: require the expected
typand verify the audience and issuer as well; the lab showstyponly.
What the tests cover, and what they leave open
Verified with Node.js 22.23.3 and jose 6.2.12 (node --test, 7 tests passing): all seven behaviors above, including the exact jose error for an unrecognized critical header parameter. I read RFC 7519 section 4 and section 7.2, RFC 7515 section 4.1.11 and RFC 8725 section 3.11 in the RFC text, and the macaroons paper’s verification procedure.
Not verified: other JWT libraries (in particular how they treat crit and unknown claims), asymmetric algorithms (the lab uses a shared HS256 key for brevity), token revocation, and behavior against any real resource server. The “old verifier” and “new verifier” are three functions in one file, a model of versions over time, not a deployment.
Before the next claim ships
A verifier that ignores what it does not understand is only as safe as the worst thing a new claim can say. If the claim can add, ignoring it costs nothing. If it can take away, put the effect into a value everyone already enforces, and decide the missing case before it happens.