Omri Gazitt

Two questions, not one

· 11 min read

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 scope parameter. Completely legal, extremely common.
  • An ID-JAG request. scope is 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_type and an audience but 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_act claim makes a statement that one party is authorized to become the actor and act on behalf of another party.

RFC 8693 §4.4

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:

  1. Issuance authority. May this party obtain a token of this kind, aimed at this audience, at all?
  2. 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:

both must permit,or nothing is issuedpermitted subsetscope tuples - access authority

invoices:read
evaluated for Alice

invoices:write
evaluated for Alice

gate tuples - issuance authority

subject gate
may a token of this kind exist
for Alice at this audience?

requesting party gate
may the gateway be the one
to obtain it?

token

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:

issue:access_token:token_exchange

workload:
spiffe://cluster/ns/prod/sa/gateway

audience:
https://api.partner.example

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:

The rest of the series:

  1. 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.
  2. 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.