A policy can deny a request and still leak names.
Two code paths read the same authorization model, and only one of them gets tested.
On 16 September 2026 the OpenFGA project published an advisory for CVE-2026-61709, fixing a ListUsers call that returned a user the model was built to exclude.
ListUsers answers a different question from the usual check, because it returns the set of everyone who can reach an object rather than a yes or no for one caller.
The project rated the issue 5.3, filed it as incorrect authorization rather than a data loss bug, and named v1.18.1 as the fixed release.
An authorization model can answer every access check correctly and still name the people it exists to hide, because deciding and listing are two implementations of one policy and testing tends to stop at the first one.
Where deciding and listing diverge
OpenFGA stores access as relationship tuples and derives permissions from them, the shape Google described in its Zanzibar paper, so a permission question becomes a graph traversal instead of a table lookup.
The check API walks that graph for a single subject and returns true or false.
ListUsers has to run the same traversal backwards, turning one relation into the set of subjects that satisfy it, so every wildcard, negation and intersection in the model has to survive being inverted.
A negative rule is easy to evaluate for a single subject and hard to evaluate as a set, because the set has to be built from everything the model grants and then filtered by everything it excludes.
Intersections are the awkward case, because a correct answer depends on all operands agreeing at once rather than any one of them holding.
If your instinct is to verify every request against the model rather than trusting a cached answer, this is the zero trust architecture argument applied to authorization.
The shape that failed
The advisory lists four preconditions, and each one is a construct teams write on purpose.
First, a relation defined as an intersection where at least one operand is an exclusion, such as rel1: (public_user but not blocked) and rel2.
Second, the base side of that exclusion granted through a wildcard such as user:*, which is how most models express signed in users minus a blocklist.
Third, an excluded user who is also granted a concrete tuple through the other operand of the intersection, which is the detail that makes the bug subtle.
Fourth, an application that uses ListUsers to enumerate who has access, or to enforce a decision on what it returns.
The first two preconditions are ordinary patterns that teams adopt deliberately, and the third is a coincidence of data rather than a design choice.
Inside that combination, the correct answer for the blocked user is a denial, because an intersection holds only when all of its operands hold and the exclusion removes them from the first operand.
ListUsers returned them anyway, which is why the record carries both improper preservation of permissions and incorrect authorization.
Nothing in the model is wrong on paper. The second implementation of it simply disagrees with the first, and a test suite built on allow and deny assertions would never have caught the change.
Who is affected, and by how much
Anyone reading access through ListUsers on an affected release is exposed, and the leak is a membership list rather than a permission grant.
That distinction matters for triage, because a wrong name in a list does not by itself open anything.
The population at risk is wider than systems with a ListUsers endpoint, because the same wrong set flows into admin consoles, approval queues and internal directories.
The 5.3 score looks fair, since the caller needs a valid session, attack complexity is high, and the impact is disclosure alone.
That makes it a defect worth patching promptly rather than a break glass event, and the remedy is a version bump.
What it does not tell you is whether your own model carries the same disagreement somewhere else in it.
Three things to do this week
1. Test both paths with the same fixtures
For every subject and object pair whose access you assert, assert the same expectation through the listing API, and fail the build when the two answers disagree.
A suite that only exercises the decision path stays silent about the endpoint that returns sets, which is exactly where this class of bug lives. The API security check is a short way to shake the dust off that habit.
2. Write one fixture shaped like this failure
Exclude a subject from a wildcard based operand, grant that same subject a concrete tuple through another operand of the intersection, then list the relation and assert the excluded subject is absent.
That fixture encodes the construct that broke, and it takes minutes. If the vocabulary of subjects, relations and objects is new to you, the authentication and authorization fundamentals cover it.
3. Bound what a listing API is allowed to influence
If a listing result fills a dashboard, an export, a search index or a cache that something later reads, treat it as unverified data and keep the decision itself on the check path.
A listing answer is a hint about who might have access, while only a check is a verdict. Upgrade to v1.18.1 and read the advisory for the exact preconditions before deciding you are unaffected.
The authorization model was never the problem
The old assumption was that a correct authorization model makes every API built on it correct.
The new reality is that each API is its own implementation of that policy, and only the paths you test deserve trust.
If you had to name the relation in your own model whose listing API has never been tested against an exclusion, which one would it be?