Omri Gazitt

Claims enrichment is a search

· 15 min read

The last post sorted everything a policy decision point may say back to an authorization server into two tiers, and flagged one case as belonging here: the groups, roles, and entitlements claims that RFC 9068 says a JWT access token should carry.

They are a different kind of thing from the rest of token shaping, and AuthZEN has an operation built for them. Following that through turns out to change the shape of the authorization server: once these claims arrive from their own call rather than riding back with a decision, the AS becomes a component that composes several answers into one token, and four problems fall out of that which are not obvious in advance.

A decision and an enumeration are different questions

An evaluation answers a closed question. May this subject do this thing to this resource. One tuple in, one boolean out, and the shape of the answer is fixed before the question is asked.

Populating groups asks an open one. Which groups does this subject belong to. There is no tuple, because the thing you want back is the set of resources that would make a tuple true, and you do not know how many there are.

It is tempting to collapse the two, because for one whole class of policy engine the collapse works. A policy-based engine evaluates arbitrary expressions over its inputs and returns whatever the policy says to return, so a policy asked “may this token be issued” can perfectly well also compute the subject’s group memberships along the way and attach them to the response context. Nothing stops it. If your mental model of a PDP is Rego or Cedar or a rules engine, getting enrichment out of the evaluation response looks free.

It stops looking free the moment you ask what a relationship-based engine would do with the same convention.

Give a Zanzibar-shaped engine the tuple “may Alice be issued an access token for this audience” and it does a check. It walks whatever edges it needs to walk to settle that one question and it stops. It has no reason to also enumerate Alice’s group memberships, because nothing in the question mentioned groups, and an enumeration is a different traversal from a check. Topaz, SpiceDB and OpenFGA all expose those as different operations, with different cost profiles, for that reason. To get the memberships out of such an engine you have to ask for them separately.

Which is to say: you have to send it a search.

So routing these claims through the evaluation response would be a convention one family of engines could implement and the other would have to be talked into faking. That is a bad basis for an interoperability profile, and it is the same discipline post three applied to the request path: put the thing in the slot the API built for it.

AuthZEN already has the right API

The useful part is that the thing that was needed already shipped.

AuthZEN 1.0 has three Search APIs alongside evaluation: Subject Search, Resource Search, Action Search. Each one takes two legs of the tuple and enumerates the third. They are in the specification because a decision API that can only answer closed questions is not enough to build with, and this class of question - enumerate what this subject can reach - kept coming up in every deployment conversation the working group had. I was a co-chair while that was being decided and an editor of the document it landed in. The searches were not added speculatively.

groups is a Resource Search and nothing more exotic than that. Subject is Alice. Action is member. Resource type is group, with no id, because the id is the answer rather than an input. Back comes the set.

The December 2025 interop scenario I described in the first post is this, already working, with multiple independent implementations. What has been missing is the part that makes it a contract rather than a demo: which action name, which resource type, what the claim looks like when the set is empty, and what happens when the call fails.

A relation is an action

The first thing that needs defending is member as an action name, because it looks like a hack for about ten seconds.

Then it stops. A relation is a verb-shaped fact about a subject and a resource, which is precisely what an AuthZEN action names. “Alice is a member of engineering” and “Alice may read document 7” have the same arity and the same three positions. This is the same move the framework document makes when it composes gate action names that a relationship-based engine reads directly as relation identifiers, and it needs no more justification here than it does there.

It also has a property I like more than I expected. Because the name is meaningful in an evaluation as well as in a search, every member of a rendered claim is checkable: AuthZEN says a search result used in a subsequent evaluation SHOULD yield a permit, so an auditor holding a token can take any group name out of the claim and ask the PDP directly whether that tuple is true. A claim sourced from a directory query has no such property.

The profile registers three bindings:

Claimresource.typeaction.name
groupsgroupmember
rolesroleassignee
entitlementsentitlementholder

member is settled. It is the name RFC 7643 uses for the group membership relation and the name every relationship schema language uses in its examples.

assignee and holder are the least settled call in the draft and I want to say so plainly rather than let a table imply confidence I do not have. A role is variously assigned to, granted to, or held by a subject, and an entitlement is too. One name could have covered both rows, leaving the resource type to carry the whole distinction. I gave them different names on the theory that someone reading their own policy is better served by names that differ where the concepts differ. That is a preference, not an argument, and if you have shipped a schema that says otherwise I would rather hear it now than after the registry exists.

Deployments that use other relation names configure them. The registered three are defaults, and they earn their place by being the reason an AS and a PDP from different implementers, neither configured for the other, interoperate on these claims at all.

Which means the authorization server composes

Here is the structural consequence, and it is the part of this that I think is actually interesting.

The token now depends on two or more separate calls. One evaluation, which is usually a batch, plus one search per bound claim, because a Resource Search names one resource type and AuthZEN defines no batch form for searches. Nothing on the PDP side knows these belong together. The correlation exists in exactly one place, which is the authorization server.

PDPAuthorizationServer (PEP)PDPAuthorizationServer (PEP)may be issued concurrentlypermitted, so mint,and render the claimsevaluate: gate tuple + onetuple per scopesearch: Alice, member, typegroupsearch: Alice, assignee, typeroledecisionsresult sets

Worth stating what that picture does not contain: any new obligation on the PDP. The searches are ordinary Resource Search requests carrying registered type and action names. A PDP answering them is not required to know that an evaluation went out beside them or that the results are headed for a token. Everything the profile specifies is a rule for the authorization server. That is a nice place for a profile to be, and it is a direct consequence of having moved the work out of the evaluation response.

Four problems follow. None of them is obvious in advance and all of them have to be answered before anyone can implement this twice and get the same behavior.

Ordering

An AS must never include an enrichment claim in a token it does not issue, and must never treat a search result as authorizing anything. No deriving scope from a result set, no deriving the audience, no letting a search outcome influence how the gate decision is handled.

And it may fire the searches concurrently with the evaluation and throw them away on a deny.

Those look like they are in tension and they are not, and the reason is worth being precise about. The ordering is semantic: search results are inputs to claim construction and to nothing else, so no arrangement of the calls in time can make one an input to the decision. The concurrency is a performance affordance, and the token endpoint is a place where that matters, because it sits on the critical path of every grant. Running the calls in sequence adds their latencies; running them together costs the slower of the two.

Latency and semantics usually trade against each other. Here they do not, and the only reason they do not is that the profile is strict about what a search result is allowed to be used for. Strictness bought the optimization.

Two consequences for a deployment that takes the affordance. It performs work it may discard, so a denied token request costs more than it used to. And its PDP receives searches for subjects who never get a token, so search volume there is not a count of tokens issued and no dashboard or rate limit should read it as one.

The concurrency is also where I expect implementations to go wrong. Search results arrive before the decision does, and an implementation that assembles the token as results land can find itself holding a nearly complete token and no decision, at which point issuing it is one misplaced early return away.

Rendering

A search response gives you objects: {"type": "group", "id": "engineering"}. A claim value is an array. Something has to define the mapping, and the obvious choice turns out to conflict with what RFC 9068 recommends.

RFC 9068 says to encode these claims according to RFC 7643, whose corresponding attributes are multi-valued and complex. Each value is an object with a value sub-attribute carrying the identifier, plus $ref, display, type and primary depending on the attribute, carrying a reference, a human-readable label, and classification.

A search result supplies exactly one of those. The id. The result’s type is invariant across the whole response by construction, since the search named it and AuthZEN requires results to be of that type alone, so it carries no per-member information. The rest have no source in a search result and could only be fabricated or fetched from a system this profile does not define. So a conforming complex rendering would be an array of single-member objects conveying exactly what an array of strings conveys, in a claim that rides on every single request the token is presented with.

The profile renders the identifiers directly, as an array of strings, and states the deviation rather than hiding it. RFC 9068’s recommendation is a SHOULD; the profile emits the value sub-attribute of each SCIM value and omits an enclosing object that would have nothing else in it. An AS that has to produce the complex form for some resource server that demands it is not prevented from doing so, but it is then rendering something this profile does not define, and no resource server can infer which form to expect from the presence of the profile.

The other rendering call is smaller and matters more. When a search succeeds and returns nothing, the claim is present with an empty array. It is not omitted.

That makes presence and absence mean two different things, deliberately. Presence says the enumeration was performed and this is the complete answer. Absence says exactly one thing: the claim was not populated. Had an empty result set also rendered as absence, a resource server would have no way to tell “this subject holds nothing” from “the enumeration did not happen,” which is the distinction the next problem depends on entirely.

Failure

A search fails if the PDP errors, times out, cannot be reached, returns a malformed response, or returns a result of the wrong type. What should the AS do then: issue the token without the claim, or issue nothing?

The honest answer is that it depends on the claim and on who reads it, and that the profile is not in a position to know. So it does not pick. It says what the AS may not do, and leaves the rest to configuration.

Dropping a groups claim is, in most deployments, harmless. The resource server does not find the group it was looking for and denies something it would otherwise have permitted. That is an availability problem rather than a security one, and it is why every product that does enrichment today issues the token anyway. An enrichment dependency that is unreachable for ninety seconds should not be an authentication outage. Post two called Okta’s fail-open hook a defensible choice for a claims decorator and an indefensible one for a policy gate, and this is the half of that sentence where it is defensible.

The case that argues the other way is narrower but real: a service that reads the absence of a quarantined or probation or restricted group as clearance. Paired with a claim that silently disappears during a PDP outage, that turns an infrastructure incident into a privilege escalation, with no party having behaved incorrectly. The PDP returned an error, the AS dropped a claim, the resource server applied its own policy.

Both of those are the resource server’s behavior, and the authorization server cannot observe it. What it can do is expose the choice, per claim and per application, to the administrator who does know. That is roughly the shape the existing products already have for enrichment configuration, and it is the shape the profile asks for: not a default the spec imposes, but a setting the operator sets deliberately rather than inherits.

the groups search

succeeds,
two results

succeeds,
no results

fails

claim carries
both identifiers

claim is present
and empty

claim not rendered;
issue or fail per config

The one thing the AS may never do, whichever way the switch is set, is fabricate a value: no default, no placeholder, and in particular no empty array, because an empty array asserts that the subject holds nothing, which a failed search has not established. Fail open if you have decided to, but fail open by omitting the claim, not by asserting a false one.

Pagination is not atomic

A large result set comes back a page at a time, and AuthZEN is explicit about what that does and does not guarantee.

Pagination does not guarantee an atomic snapshot of the result set. Consequently, if items are added or removed while paginating, results MAY be repeated or omitted between pages.

Authorization API 1.0, §8.2

That is honest and correct, and it is the right framing for the caller AuthZEN had in mind, which is something rendering a list. It means a claim assembled from a paginated response is a best-effort enumeration: an item added or removed while you are paging may or may not be reflected in what you sign.

Repetition you handle by deduplicating, which the profile requires you to do across the whole set of pages rather than within each one, and which you need anyway because AuthZEN recommends searches be performed transitively and a subject holding the same group by two paths comes back twice.

Omission you mostly live with. I went back and forth on this and landed on living with it: a membership that changed during the two hundred milliseconds you spent paging is a race you were going to lose somewhere regardless, and a profile that turned it into a failed token issuance would be trading a rare and minor inaccuracy for a common and major outage.

What is worth a rule is the size. Bound the number of pages you will follow for one claim, and treat exceeding that bound as a failed search rather than signing whatever you happened to collect. A deployment that trips the bound has learned something real: not that it needs a bigger one, but that a claim is the wrong mechanism for that data, and the resource server should be asking the PDP directly with the resource in hand.

The same caveat applies one level up, and for the same reason. The gate evaluation and each search are separate calls against a graph that can change between them, and nothing in AuthZEN 1.0 pins a view across calls. So a token can carry a decision made against one state and claims computed against another. Issue the calls concurrently and keep token lifetimes short and the window is small, but it does not close, and a deployment whose security depends on the gate and the claims reflecting exactly one state of the graph should know that this profile does not give it that.

Where this is going

That is the series. Three documents came out of it:

In the first post I said the argument I had been making since 2021 was true and unwinnable, and that the winnable version was narrower: if a token is going to carry authorization, it had better be minted by something that consulted a policy engine, and the policy engine had better be able to constrain what comes out. I still think that is the whole thing. What I did not expect was how much of the work would turn out to be about the boundary between the two surfaces rather than about either one of them.

The clearest example is the last rule in this post. A claim that will not fit in a token is not a token-size problem. It is a deployment discovering that this particular question belongs at the resource server, at request time, with the resource in hand, where the answer is one decision instead of an enumeration. Issuance-time claims and request-time evaluation were never competing for the job. Getting the issuance surface specified is what makes it possible to say, precisely, which questions belong on the other one.

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