Designing locale-aware authorization at Webflow

How we built locale-specific access so teams can edit regional content safely.

Designing locale-aware authorization at Webflow

Alastair Purvis
Staff Software Engineer
View author profile
Alastair Purvis
Staff Software Engineer
View author profile
Table of contents

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.

A side-by-side comparison of the resource model (one allow/deny fact per page) versus the dimension model (a matrix of allow/deny cells for each page × locale combination).
The resource model stores a fact per resource. The dimension model creates a matrix: the same resource, accessed through different contexts.

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.

The naive approach stores 3,000 facts for 200 pages × 15 locales. The context-fact approach reduces this to a single ephemeral fact injected per request.
Storing N×M facts grows with every page and locale. Context facts reduce this to exactly one ephemeral fact per request.

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.

Flowchart of a single authorize() call evaluating page access and locale access in sequence. Either axis can deny the request; when no locale context is present, the locale check is skipped.
Two restriction axes evaluated in one call. If the page check passes but the locale check fails, the user is still denied. When no locale context is present, the locale check is silently skipped.

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.

A three-row table comparing approaches to cross-locale authorization. Direct fact queries, policy negation, and derived aggregate facts each introduce a distinct failure mode; inverting the question avoids all three.
Each alternative to inverting the question runs into at least one of these tradeoffs. The inverted approach sidesteps all three.

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.

Two new locale abilities compose into existing high-level abilities (canEditStaticPageContent, canDeletePage, canMergeBranch), which already gate downstream UI components No code changes required in those components.
New locale abilities compose into existing ones. Downstream components inherit restrictions without code changes.

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.

The FactGenerator walks a relationship graph starting from the authorized resource (SitePage), following edges to SiteLocale, Site, and Workspace to produce has_relation context facts for the authorization service.
The FactGenerator walks the relationship graph starting from the authorized resource. Each edge is resolved by a registered resolver, producing has_relation facts for the authorization service.

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.

Alex Halliday
CEO
AirOps
Learn more
Aleyda Solis
International SEO Consultant and Founder
Orainti
Learn more
Barry Schwartz
President and Owner
RustyBrick, Inc
Learn more
Chris Andrew
CEO and Cofounder
Scrunch
Learn more
Connor Gillivan
CEO and Founder
TrioSEO
Learn more
Eli Schwartz
Author
Product-led SEO
Learn more
Ethan Smith
CEO
Graphite
Learn more
Evan Bailyn
CEO
First Page Sage
Learn more
Gaetano Nino DiNardi
Growth Advisor
Learn more
Jason Barnard
CEO and Founder
Kalicube
Learn more
Kevin Indig
Growth Advisor
Learn more
Lily Ray
VP SEO Strategy & Research
Amsive
Learn more
Marcel Santilli
CEO and Founder
GrowthX
Learn more
Michael King
CEO and Founder
iPullRank
Learn more
Rand Fishkin
CEO and Cofounder
SparkToro, Alertmouse, & Snackbar Studio
Learn more
Stefan Katanic
CEO
Veza Digital
Learn more
Steve Toth
CEO
Notebook Agency
Learn more
Sydney Sloan
CMO
G2
Learn more

We’re hiring!

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

Read now

Last Updated
April 16, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.