Most authorization systems resolve to one question: can this user perform this action on this resource? That works until a new axis of restriction has to compose with every existing policy. For us, that axis was locale.
When we launched locale-specific access, the product goal was clear: let an admin assign a Spanish translator to regional content without risking changes to the English site. Our existing authorization model handled pages and CMS collections well, but locale couldn't be modeled as yet another resource. Supporting it required four design shifts.
From resources to dimensions
Most authorization systems default to deny and grant access explicitly. Webflow’s works in reverse: the default is open and admins set restrictions explicitly. Locale was harder to fit in that model because in a default-open system, missing context silently bypasses the restriction.
Resource-based access control works well because it applies to a concrete object: a page, a collection, a site. That object has an ID, can be enumerated, and can carry a durable fact like “this user is restricted from page XYZ.” When you check access, the object being authorized is also the object being restricted.
A dimension sits alongside the resource and qualifies access to it. Attribute-based systems can express this, but most treat non-resource attributes as coarse guards rather than restrictions that compose with resource policy. When a designer edits the Spanish variant of a page, the page remains the object being edited, while locale supplies the context for that action. Locale may still appear as a first-class object elsewhere in the system, such as when listing which locales a user can access, but most authorization checks treat it as request context.

That distinction changes the system’s shape. It affects how you store authorization facts, how you compose checks, and how you handle operations that span the full dimension.
Shift 1: Context is better computed than persisted
The first thing that changes is persistence. In a resource model, you store a restriction fact directly on the thing being restricted: one page, one fact. Once locale enters the picture, the naive version is a relationship for every page-locale pair on every site.
A site with 200 pages and 15 locales would require 3,000 relationship facts just to represent the current state. Every new page means 15 new facts, and every new locale means 200 new facts. The storage cost grows multiplicatively.
The crucial distinction was this: resource facts and dimension facts are different kinds of data. Resource facts need to be durable because they describe the system, but locale facts describe the request, which makes them safe to compute instead of store.
We addressed this by making locale context ephemeral: inject it at request time, evaluate it in-flight, then discard it.

Shift 2: Two restriction axes must compose in one check
With resources, authorization is one-dimensional: can this user access this page? If you add a dimension, the question becomes compound: can this user access this page in this locale?
That introduces an override relationship the resource model never had to handle. A user who can edit a page but is restricted from Spanish still has to be blocked from editing the Spanish variant.
The straightforward approach is two separate policy calls: one for the page, one for the locale. But that breaks as soon as policy depends on how the two interact. A role that grants locale-wide access might override a page restriction. Split across two calls, that logic has nowhere to live. To preserve those semantics, both axes have to be evaluated in a single policy decision.
We implemented this with a composable policy rule in Oso Cloud, our authorization engine, that applies to all resource types.

When no locale is present in the request, the rule passes unconditionally, so existing authorization stays unchanged. A single authorize() call evaluates both axes, and because locale context is injected rather than stored, the check stays constant-time.
Shift 3: All-locale operations require a different kind of check
Single-locale operations are straightforward: check the locale, then allow or deny. But some operations span every locale. Deleting a page removes all of its locale variants. If a user is restricted from Spanish, letting them delete the page breaks that promise.
This is where relation-based authorization starts to strain under a restriction-based model. Systems in this family are typically optimized for existence checks: does this relationship exist? But all-locale operations require the inverse: verifying that no restricted locale exists anywhere in the set. That is a different kind of query, and it scales with the size of the set instead of resolving in constant time.
Each obvious approach had a cost. Direct fact queries bypass policy evaluation, so role-based grants disappear. Derived aggregate facts create sync risk. Policy-language negation requires enumerating the full locale set up front, which pushes complexity into correlated subqueries and undocumented corners of policy language.
We chose to invert the question: instead of asking whether the user has any restrictions, we ask which locales they can edit, using a policy-evaluated query, then check whether that set is complete. That preserves authorization semantics, avoids negation, and confines the only application-side logic to set arithmetic.

Shift 4: Authorization must be implicit, not opt-in
At Webflow, we have thousands of endpoints and components. When authorization depends on request context like locale, every caller has to pass that context correctly. Wiring that in by hand is slow, fragile, and easy to miss. On both the frontend and backend, the answer was the same: make enforcement structural, not opt-in.
On the frontend, that meant reusing what we already had. Our page and collection restriction system already composed high-level abilities like “can edit page content” and “can delete collection.” We folded locale checks into those same abilities, so downstream components inherited locale restrictions automatically, with no code changes.
The result is that consumers do not need to know about locale in order to enforce it correctly.

On the backend, we had a similar problem: context facts had to be generated reliably across every entry point. Instead of scattering locale resolution logic across hundreds of endpoints, we centralized it in a relationship resolver registry.
Each resolver declares a relationship and how to extract facts from request context; a fact generator walks the graph and produces the facts the authorization service needs.

This also closed a subtle failure mode: if an endpoint calls the authorizer without locale context, the locale check is skipped and the restriction is bypassed. By making context generation automatic, the registry removes that manual step entirely.
Beyond locale
While Attribute-Based Access Control (ABAC) systems can express dimensional composition, most implementations don't make it ergonomic to enforce across every surface, especially in a default-open system. We built locale-specific access to solve that, but what surprised us most was how much of the solution generalized. The pieces we built for locale, such as orthogonal composition and automatic fact generation, do not depend on locale itself. The next dimension can plug into the same registry, the same policy structure, and the same composition model.
If you're hitting similar friction, the real test isn't whether your policy language can express the new axis, but whether a new endpoint can silently get it wrong.



















We’re hiring!
We’re looking for product and engineering talent to join us on our mission to bring development superpowers to everyone.




