Tier-Permission Paths Are a Bug Class: Why the Same Shape Keeps Shipping

Written by the Rafter Team

If you stack the Canvas / Instructure ShinyHunters breach and the Codex branch injection postmortems next to each other, the architecture diagrams look unrelated. The bug class is identical. In both cases, the developer treated a tier label — "Free-For-Teacher" on Canvas, "agent-scoped token" in the Codex case — as a trust boundary. In both cases, an attacker found a path where the tier label could be set or assumed, and the privileges flowed accordingly.
The bug class predates both. It is one of the oldest authorization mistakes in web applications. The reason it keeps shipping in 2026 is that the architectures got more complex, and the names of the tiers got more specific, but the question "is this label a trust boundary?" got asked less often.
Any control that depends on a string-valued tier label, a role flag, or a tenancy scope being correctly set across every code path is a single misconfiguration away from a privilege boundary failure. Treat tier labels as hints, not as boundaries. The boundary is the underlying authorization check.
The shape of the bug
A system has multiple privilege tiers. A user belongs to one tier. The tier is represented as a value somewhere — a column on a user record, a claim in a JWT, an environment variable in a container, a label on a service principal. Authorization checks throughout the codebase reference the tier value.
The bug is that the tier value is not a trust boundary. It is data. Data can be set, copied, propagated, defaulted, and forgotten. Anywhere in the codebase that a tier value gets initialized, mutated, or assumed has the potential to violate the privilege model. The "trust boundary" is not the tier label — it is the discipline of every code path that touches it.
In the Canvas case, the Free-For-Teacher tier was supposed to scope a teacher to their own classrooms. A path existed where a Free-For-Teacher account could be elevated by a sequence of requests that the application's primary authorization checks did not contemplate. The tier label was correct. The check around it was incomplete.
In the Codex case, an agent token was scoped to a specific container, with privileges intended to be ephemeral. A path existed — branch injection in this case — where the agent token's effective scope could be expanded into adjacent contexts. The token's nominal scope was correct. The check that the scope held across all paths was incomplete.
Same shape. Two products. Two unrelated codebases. One bug class.
Why this keeps shipping
Three architectural shifts converged to make the bug class more common, not less, in 2026.
Tenant labels proliferated. A typical SaaS application now has plan tiers, organization roles, workspace roles, project roles, and per-resource access grants. Each label is a string that needs to be checked at every authorization point. The product team adds a new tier ("Enterprise+", "Starter-Pro", "Free-For-Teacher"); the authorization code paths multiply. Most checks are correct. The exceptions are the bugs.
Agent identity blurred the human/service boundary. An AI coding agent acting on behalf of a developer has, at the protocol level, the developer's identity. The agent's effective tier — what it should be allowed to do — is sometimes constrained to "developer" and sometimes constrained to "agent acting on behalf of developer within a scope." The two are different. Frameworks that conflate them ship the Codex-shaped bug.
Container-bound credentials made scope semantically dependent on runtime. A credential issued to a container is supposed to be valid only inside that container. The actual enforcement happens in the credential's verifier — usually a service that needs to know what "inside this container" means at every call site. When the container's identity gets impersonated, propagated, or inherited, the credential's scope expands beyond its design.
Each of these is a feature of modern architecture. None is inherently a bug. The bug is treating any of them as a trust boundary instead of as a hint that needs a real boundary somewhere else.
What the boundary actually looks like
A real trust boundary has three properties.
It is checked at every privileged action, not at session establishment. Tier labels set at login and trusted for the rest of the session are not boundaries. They are caches. Caches go stale. The real boundary re-checks the actual authorization at the point of action.
It is checked against a source of truth, not against a copy. The tier label in a JWT is a copy of the tier label in the database. If the database changes — the user is downgraded, the organization is suspended — the JWT does not. The real boundary is the database read (or whatever the source of truth is), not the JWT claim.
It fails closed. When the boundary cannot be evaluated — the database is unreachable, the policy service is down, the role lookup returns ambiguous — the default is to deny the action, not to allow it. Most tier-permission bugs ship because the failure mode of "I couldn't evaluate the policy" is "treat as default tier, allow." The reverse is the security default.
The Canvas and Codex postmortems both flagged a violation of one or more of these properties. The remediations in both cases moved the check closer to source-of-truth and farther from the cached tier label. That direction is the right one.
How to find the bug class in your own code
Ask the questions every architecture review should ask, but rarely does:
- Where does each tier label originate? Database? Token claim? Environment variable? Hard-coded default?
- Where is each tier label consumed? Search for every reference. Authorization function? Direct conditional? Implicit assumption (e.g., "if the agent has this header, it's an internal call")?
- For each consumption site, what is the source-of-truth re-check? If there is none, document why it is safe to trust the cached value at that point.
- For each tier label, what is the failure mode? When the label cannot be evaluated, does the code allow the action or deny it?
- For each tier label, what is the propagation surface? Does the label cross service boundaries? Get logged into systems that other components read? Get included in URLs or headers that downstream components trust?
Most teams have never written this down for their own code. The exercise produces a list of places where the tier label is checked but the source-of-truth is not. Each of those places is a candidate for the next Canvas-shaped or Codex-shaped postmortem.
The Rafter angle
rafter run includes authorization-pattern checks that flag the precondition of this bug class: a tier label read from a token without a source-of-truth check, a role flag consumed by a conditional without a fail-closed branch, a tenancy scope crossed by data flow that does not re-verify scope at the destination. These are pattern checks, not full proofs — they surface candidates that a human still has to evaluate. --mode plus adds agentic deep-dives that trace specific data flows from tier-label origin through every consumption site.
No scanner replaces the architectural review above. What a scanner does is run the review on every PR instead of on the quarterly cadence the manual version inherits. The bug class is decades old. The remediation is also decades old. The discipline is what slips when the labels multiply faster than the review cadence.