Two questions, not one
If you are going to put a policy decision point behind a token endpoint, the first thing you have to decide is what question to ask it.
AuthZEN’s question has a fixed shape: a subject, an action, a resource, some
context. So the mapping seems obvious. The subject is whoever the token will
represent. The resource is what the token is for. And the action is the scope
being requested. A client asks for invoices:write, you ask the PDP about
invoices:write, you get back yes or no.
That mapping is right. It is also, on its own, wrong in a way that took me a while to see clearly, and the place it breaks is not an edge case. It is token exchange, which is to say it is every interesting thing the OAuth working group has shipped in the last five years.
The request with nothing to ask about
Start with the mechanical failure, because it is easy to state.
The scope-as-action mapping assumes there is a scope to ask about. Quite often there isn’t:
- A client credentials request with no
scopeparameter. Completely legal, extremely common. - An ID-JAG request.
scopeis OPTIONAL in that spec, deliberately, and a request that names a target audience and nothing else is a normal thing to send. - A token exchange that names a
requested_token_typeand anaudiencebut no scopes.
Under the naive mapping, each of these produces zero questions. Not a denial, not a permissive answer. Nothing to ask. The PDP is never consulted, and an authorization server that believed it had externalized its issuance policy has silently gone back to deciding by itself, in exactly the deployments where the delegation is most consequential.
You could paper over it. Invent a synthetic action, * or _issue, and move
on. I tried that and it felt like a hack, which is usually a sign that the
model is missing something rather than that the notation is.
It was.
may_act is the tell
RFC 8693 defines a claim called may_act. It exists to say that one party is
authorized to become the actor for another. And the spec is explicit about why
it is a separate thing from everything else in the token:
The
may_actclaim makes a statement that one party is authorized to become the actor and act on behalf of another party.
Read that next to a scope. A scope says what may be done to a resource.
may_act says nothing about resources at all. It is a statement about a
relationship between two principals.
That is not the same kind of privilege, and OAuth already knew it. The authority to act on someone’s behalf and the authority to do a particular thing are different edges in the graph. I had been trying to model both with one edge.
So there are two questions:
- Issuance authority. May this party obtain a token of this kind, aimed at this audience, at all?
- Access authority. May this scope be exercised at that audience?
The scopeless request has no instance of question 2. It still has an instance of question 1, and question 1 is the one that was silently going unasked. That is the whole fix for the mechanical problem: the issuance question is always there, and in the ordinary case it just collapses into the scope question because both get answered by the same permit.
But once you have separated them, something more interesting falls out.
The second party
Every issuance flow other than token exchange decides what a subject may obtain. Authorization code, client credentials, refresh: one principal, one question.
Token exchange has two principals. A gateway presents Alice’s token and asks for a token to act as Alice against a partner API. There is Alice, and there is the gateway, and they are not the same party with different hats. They have independent authority and either one can be the reason to say no.
Alice may be perfectly entitled to invoices:write at that partner API. That
tells you nothing about whether the gateway is entitled to exercise it for her.
Conversely, the gateway may be a thoroughly trusted piece of infrastructure
that is nonetheless not allowed to act for this user, or not allowed to
delegate into this trust domain.
So the shape is:
Gate denials are fatal. Scope denials are not: a request for three scopes that comes back with two permits should mint a token bearing two scopes, which is behavior RFC 6749 already anticipates and ID-JAG states outright when it says granted scopes may be a subset of those requested.
And note which subject the scope tuples carry. They are evaluated for Alice, not for the gateway. Scope is access authority, and the access being exercised is hers. A delegate cannot acquire access the principal does not have by asking for it on her behalf. The gateway’s own privilege is narrower and prior: the right to act as her at all. That is what the gate decides.
Why not just read may_act?
Here is the objection I expect, and it is a good one. RFC 8693 already gives
you a place to put this. The subject token can carry may_act naming the
gateway. Check that the requesting party is in there, and you are done. Why
invent a second policy call?
Because may_act is an assertion, and the gate is a decision.
may_act was written into the subject token when that token was minted, by
whoever minted it, reflecting what was true then. The token is now being
presented for exchange at some later moment. Everything that makes a
permissions snapshot go stale between issuance and use applies just as much to
a delegation snapshot. The gateway may have been decommissioned. The trust
relationship may have been revoked. Alice may have changed roles.
This is the same argument I have been making about scopes in tokens since 2021, and it would be strange to spend a whole series on it and then accept a stale claim as the answer to the delegation question. Consulting a policy engine at issuance is precisely a rejection of “an assertion baked in earlier is good enough.”
So may_act goes to the PDP as context, and a PDP whose policy wants to
require it can require it. What it must not do is substitute for the
decision. Its presence doesn’t grant the gate, and its absence doesn’t deny
it.
Impersonation is the case that matters
RFC 8693 supports two modes. In delegation, the issued token carries an
act claim naming the actor: the token says “Alice, as acted for by the
gateway.” In impersonation, it doesn’t. The token just says Alice.
Impersonation is where the second gate earns its keep, and it is the case people forget, because there is no actor in the output to remind you there was one in the input.
An impersonation token is indistinguishable from one Alice obtained herself. Nothing downstream can tell that a different party asked for it. No resource server, no gateway, no audit trail on the receiving side can apply any policy to the gateway’s involvement, because from where they sit the gateway does not exist.
Which means the token endpoint is not the best place to evaluate that party’s authority. It is the only place.
I think this is the strongest single argument for the whole framing. Not that
externalizing issuance policy is elegant, but that there is a privilege here
which is exercised on every impersonation exchange in every deployment, and
which currently gets evaluated by whatever the vendor’s rules engine happens to
do, if anything. RFC 8693 §4.4 even names the party whose authority is in
question - “the client (or party identified in the actor_token)” - and then,
being a good spec, declines to say how you decide.
Why it has to be a tuple, not a field
There is a design constraint here that shaped everything above, and it is worth being explicit about because it is the reason this looks the way it does rather than something more compact.
AuthZEN makes exactly five fields mandatory in an evaluation: subject.type,
subject.id, action.name, resource.type, resource.id. Everything else,
the property bag on each entity and the context object, is optional. That
asymmetry is not bureaucratic. It is what lets a single request shape be
understood by policy engines with very different internals: attribute- and
policy-based engines that evaluate expressions over arbitrary input, and
relationship-based engines in the Zanzibar lineage that reason over a typed
graph of subjects, relations, and objects.
I want to be careful here, because there is a lazy version of this argument that says Zanzibar-style engines cannot consume anything beyond the five-tuple. That is not true. Contextual tuples are an ordinary query-time input, and a PDP can perfectly well project properties or context into them. The real point is about where a profile puts the load. The five-tuple has a shape every conforming PDP reads the same way; a free-form bag does not, and leaves each PDP to infer the semantics on its own.
So the test I have applied to every part of this mapping is: could a PDP
implement it reading only the five-tuple? If answering the question requires
digging into context, the mapping is wrong. A profile that carried its
decision-critical inputs in free-form properties would nominally use AuthZEN
while quietly making itself unportable, which is a strange thing for an
interoperability profile to do.
That test is what rules out the compact alternatives. Putting the requesting
party in subject.properties.actor would be tidier on the wire and unreadable
to half the ecosystem. As a tuple, it is an ordinary edge:
A graph engine can answer that. So can an ABAC engine, which can additionally
bring may_act and acr and device posture to bear from context. Both are
first-class, and neither is required to become the other.
The relation name has three segments because the same test applies to the grant. Whether this is a token exchange or a client credentials request is not recoverable from the subject, the audience, or the token type, and a policy that lets a workload hold a token for itself but not exchange for one has nothing to attach to unless the grant is in the tuple. So it goes in the action name, as a registered short name rather than the grant URI.
That last part is a concession to reality, and it is worth being concrete
about whose reality. Relation identifiers are not free-form strings in any
of the three relationship engines I know best. OpenFGA caps them at 50
characters, which
issue:access_token:urn:ietf:params:oauth:grant-type:token-exchange blows
past at 66. SpiceDB allows lowercase letters, digits, and underscores, so no
hyphens. Topaz - which my company built, so I have no excuse for getting this
one wrong - allows dots and dashes too, but wants the name to start with a
letter and end alphanumeric. And none of the three permits a colon.
So the short names are bounded to [a-z][a-z0-9_]{0,30} and the composed
name to 50 characters, which makes the port to any of them a single
mechanical substitution: issue_access_token_token_exchange. The bounds are
not there to be tidy. They are there so that the substitution can never
fail, which is what lets a policy written against these names move between
engines without being rewritten.
Token exchange stops being the special case
The thing I did not expect when I started working this through is which direction the generality runs.
I assumed the authorization code flow was the base case and token exchange was an elaborate variant to be handled once the simple thing worked. It is the other way around. Token exchange is the general shape: two parties, a target, a set of privileges, a token type that is itself a variable of the request. Every other grant is that shape with pieces held constant. Authorization code is token exchange where the requesting party and the subject collapse together, and where the client does not get to name the token type it wants.
Which is a good sign for the model, and also just a nicer place to stand. Identity chaining, ID-JAG and transaction tokens stop being three things to support and become three sets of parameters on one decision. What each one adds turns out to be small and specific: ID-JAG needs a second evaluation level, because the grant’s own audience and the audience of the access it describes are different values and both matter. Transaction tokens need the requesting workload evaluated for scope as well as for the gate, because their spec asks for exactly that. Neither needed new machinery.
Where this is going
The framework is an Internet-Draft, and there is now a binding for this family alongside it:
- AuthZEN Profile for OAuth 2.0 Token Issuance - the framework: the mapping and the response vocabulary (editor’s copy).
- AuthZEN Binding for OAuth 2.0 Token Exchange - RFC 8693, identity chaining, ID-JAG and transaction tokens. This post is its argument (editor’s copy).
The rest of the series:
- The answer is not a boolean. How a decision narrows a token, and why “may an implementation ignore this field?” turns out to have a mechanical answer rather than a judgment call.
- Claims enrichment is a search. Using AuthZEN’s search endpoints to populate the
groups,roles, andentitlementsclaims of RFC 9068, formalizing the December 2025 interop scenario.
Comments, disagreements, and pull requests are welcome in the repo.