Omri Gazitt

The decision every OAuth spec declines to make

· 12 min read

Every OAuth authorization server answers the same question, thousands of times a second, in every deployment on earth:

This party is asking for a token. Should I issue one, and what should be in it?

It is an authorization decision. It has a subject, an action, a resource, and a context. It is, structurally, the same shape as the decision a resource server makes when it decides whether to honor a request.

We have spent the last several years building interoperable machinery for the second decision. For the first one, we have written, over and over, some variation of the phrase out of scope.

Start with the boring case

Forget delegation, agents, and cross-domain federation for a moment. Take the plainest thing an authorization server does: the authorization code flow.

A client redirects a user, the user authenticates, the client comes back with a code and asks for an access token with scope=invoices:read invoices:write. The authorization server now has to decide which of those scopes to grant. Not can this client ever request them - that part is client registration. The actual question is narrower and harder: should this scope be granted, to this client, acting for this user, against this API, right now?

Almost every deployment answers it the same way. Somewhere there is a table - in a config file, a database, an admin console, a switch statement - mapping clients and users and groups to allowable scopes. It is written once, by hand. It duplicates authorization logic that also exists in the resource server. It has no way to express “this scope, but only for resources in the user’s own region,” because scope strings have no room for that. And it is unique to the vendor, so migrating authorization servers means rewriting it.

This is not an exotic problem. It is the most common authorization problem in OAuth, and it has no interoperable expression.

Four specifications, four polite refusals

The pattern is easiest to see in the specs that define the most interesting issuance moments. Each one arrives at the decision, names it, and steps around it.

Token Exchange (RFC 8693) compresses the entire thing into a subordinate clause:

If the request is valid and meets all policy and other criteria of the authorization server, a successful token response is constructed…

RFC 8693 §2.2.1

“Meets all policy and other criteria” is doing an enormous amount of work in that sentence, and nothing in the document defines it.

Transaction Tokens is more direct about it:

MUST validate the requesting workload client authentication and determine if that workload is authorized to obtain the Txn-Tokens with the requested value(s). The authorization policy for determining such issuance is out of scope for this specification.

Identity Chaining mentions the decision mainly to note that it may go the other way:

Due to policy the request MAY be denied (for instance if a trust relationship with trust domain A is not established).

And the Identity Assertion Authorization Grant (ID-JAG) gets closest of all - close enough to enumerate precisely what the policy decides:

The IdP Authorization Server evaluates administrator-defined policy for the token exchange request and determines if the client should be granted access to act on behalf of the subject for the target audience, resources, scopes, and authorization details.

Read that again, because it is a specification of an interface written in prose. There is a subject. There is an actor acting on its behalf. There is a target audience. There are resources, scopes, and authorization details being requested. There is a policy that renders a verdict on the combination. ID-JAG goes further and contemplates the verdict being partial:

The granted scopes MAY be a subset of the requested scopes based on policy.

So the decision has a return value richer than a boolean, too. Everything about the interface is specified except the interface.

None of these are oversights. Declaring policy out of scope is the correct editorial choice for each document in isolation - a token exchange spec should not also be a policy language spec. But four documents making the same correct local choice add up to a conspicuous hole, and the hole is in the place where every one of them makes its most consequential decision.

The asymmetry

Here is what makes this strange in 2026 rather than 2016.

We have a standard for externalizing an authorization decision. The OpenID Foundation’s AuthZEN Authorization API 1.0 reached Final earlier this year. It defines a Policy Enforcement Point asking a Policy Decision Point a question shaped as subject, action, resource, context, and getting back a decision. It has a batch form, search endpoints for enumerating what a subject can reach, and a discovery document.

And essentially every deployment of it points in one direction: at the resource server. The PEP is an API gateway, a service mesh sidecar, an application middleware. The decision being externalized is may this request proceed.

Almost nobody pointed it at the authorization server.*

Which is odd, because the token endpoint is an unusually good place for a PDP to sit. The decision happens once per token rather than once per request, so the latency budget is generous. The inputs are already assembled and already authenticated. And the output is a credential that gets presented many times afterward - so a decision made well at issuance pays for itself across every subsequent call, and a policy that narrows a token at issuance narrows it everywhere it is later presented.

Everyone built the hook. Almost nobody built the decision.

When a standard is missing from somewhere it is needed, you can usually tell, because every vendor will have built something proprietary there. That is exactly what happened here. As of mid-2026, five major authorization servers expose an extension point at token issuance:

ProductMechanismRuns where?Can it add claims?Can it deny issuance?Can it change scopes?
Microsoft Entra IDCustom claims provider, on the token issuance start eventExternal REST API you hostYesNoNo
OktaToken inline hookExternal service you hostYesNo - see belowNo (claims and lifetime only)
Auth0Actions, credentials-exchange triggerSandboxed JS inside Auth0YesYes - api.access.deny()Not documented
KeycloakProtocol mappers / provider SPIIn-process Java, deployed as a JARYesNoNo
AWS CognitoPre token generation Lambda (V2/V3)Lambda in your accountYesNoYes - both directions

So the moment is real, it is universally recognized, and it has five mutually incompatible interfaces. That much I expected. Three things I did not expect:

These are claims-enrichment hooks, not authorization decisions. Compare the last two columns. Five out of five can add claims; one out of five can refuse to issue. Only Auth0 can - and it can only do that because Actions runs inside the tenant as sandboxed code rather than as a call to an external service. Every mechanism here that actually consults an external system (Entra, Okta) can add claims and nothing more. The industry externalized the data that goes into a token. With one exception, it did not externalize the decision to issue one.

Okta’s hook fails open. This is the detail that convinced me the framing matters. Okta’s inline hook response format has an error object, but for the token hook specifically, the documented behavior when your service returns an error is that “the Okta process flow continues with the original token returned.” If your external service is down, misconfigured, or actively rejecting, the token is issued anyway. That is a defensible choice for a claims decorator and an indefensible one for a policy gate - which is precisely the point. It was built as the former, and the distinction was never drawn, so nothing stops an operator from deploying it as the latter.

Cognito independently invented two-thirds of the design. Its pre-token Lambda can narrow a token’s scopes (scopesToSuppress), and it publishes a table of claims your function may not touch - sub, iss, exp, iat, jti, nbf, azp, acr, amr, client_id, token_use and more. That is a reserved-claim list, arrived at from first principles by an engineering team that needed one. It is close to the list I ended up with. Convergent evolution is a good sign you are looking at something real.

But Cognito also offers scopesToAdd, and there it goes wrong in the instructive way. A hook that can add scopes can hand out authority that no policy evaluation ever granted - the customization step becomes a privilege escalation path rather than a constraint on one. That asymmetry is the thing a specification has to nail down, and it is the reason the profile I’ll describe in this series has exactly one inviolable rule about its return path: a decision may narrow what would have been issued, and may never broaden it.

Five proprietary hooks, none of which can safely say no. That is what a missing standard looks like.

What closing it actually requires

Mapping an OAuth token request onto subject/action/resource/context is not hard, and the obvious mapping is mostly right. The subject is the party the token will act as. The resource is what the token is for. The context carries client identity, authentication strength, sender-constraining key, device posture.

The action is where it gets interesting, and where I think the naive mapping is actually wrong.

The intuition is that the action is the requested scope: the client wants invoices:write, so action.name is invoices:write and the PDP says yes or no. That works, and for the boring authorization-code case it is exactly right.

But it quietly assumes there is a scope to ask about. Consider a client credentials request with no scope parameter at all - legal, common, and under this mapping it produces zero questions to ask the PDP. Consider ID-JAG, where scope is OPTIONAL. Consider a token exchange that names a requested_token_type but no scopes. In all of these, the thing being decided isn’t “may this scope be granted.” It is something prior to that: may this party obtain a token of this kind, aimed at this audience, at all?

Those are two different questions, and conflating them is a real modeling error rather than a stylistic one. RFC 8693 already knows this: it defines a may_act claim precisely because the authority to act on someone’s behalf is not the same privilege as the authority to do a particular thing. A delegation gate and an access check are different edges in the graph.

So the mapping needs both: a gate question about issuance and delegation authority, and scope questions about access authority. In the common case they collapse into a single call. In the scopeless case, only the gate exists - and without it, there is nothing to evaluate.

The other half is the return path. ID-JAG already told us the answer isn’t a boolean: granted scopes may be a subset. So the PDP needs a way to say “yes, but narrower” - a shorter lifetime, fewer scopes, a tighter audience - and the authorization server needs a rule guaranteeing that such a response can only ever narrow what would otherwise be issued, never broaden it. That rule turns out to do most of the security work, and it has a pleasant corollary: you can decide mechanically which parts of a PDP response an implementation is allowed to ignore. It may ignore anything whose absence makes the token narrower. It must not ignore anything whose absence makes it broader.

Where this is going

I’ve written the framework up as an Internet-Draft:

It stands on its own for the everyday grants - authorization code, client credentials, refresh - and it works two of them end to end. Bindings pick up the families that add structure it doesn’t model, which in practice means the ones with a second party. Subsequent posts in this series:

  1. Two questions, not one. Issuance authority versus access authority, why may_act is the tell, and what a policy model looks like that can answer both. This is where token exchange stops being a special case and starts being the general one.
  2. The answer is not a boolean. How a decision narrows a token, and why “may an implementation ignore this field” has a mechanical answer.
  3. Claims enrichment is a search. Using AuthZEN’s search endpoints to populate the groups, roles, and entitlements claims of RFC 9068 - formalizing the December 2025 interop scenario.

Comments, disagreements, and pull requests are welcome in the repo.