Decentralized Authorization: Sidecars, Edge & Multi-Cloud
Where the PDP actually runs: the sidecar pattern (Envoy ext_authz, OPA-Envoy, Istio and Linkerd authorization policies), running policy at the CDN edge before a request reaches your infrastructure, WASM as the portability layer that lets the same policy run in all these places, and what changes about policy distribution and the new enemy problem once the decision engine is spread across regions and clouds instead of living in one place.
Revoked in Frankfurt, allowed in Virginia
The cashback company opens an EU entity, and Sara moves from the US finance team to the new team in Frankfurt. On her last day in the old role an administrator in Frankfurt removes her from the US reporting group. Ten seconds later, out of habit, Sara opens a US quarterly report from a bookmark. The request lands in the US region, the nearest one to the link she followed, and the authorization engine there reads its local copy of the relationship data. The removal is still on its way across the Atlantic. The report opens.
Nothing in that story is a bug in the policy or in the model. The rule is right, the tuple was deleted, and the engine evaluated correctly against what it could see. What went wrong is where the decision was made and what that place could know at that moment. Three articles ago, describing where a PDP can live, the XACML article said it “can be a library compiled into the application, a sidecar running next to each service, or a central service every app calls over the network”, and called that a choice with real consequences for latency, freshness and blast radius. Every article since has treated “where does the decision run” as a detail for later. This one fills it in.
The short reason this is its own topic: placement isn’t just an infrastructure choice; it changes what the decision engine can promise. A PDP one network hop away can be more consistent, but it’s a single point of failure and extra latency on every request. A PDP in a sidecar is fast and survives network partitions, but now it exists in hundreds of copies that all need the same policy. A PDP at a CDN edge, possibly thousands of kilometers from your database, physically can’t see data that lives only in your origin region. None of these are bugs to engineer away. They’re the real shape of the trade-off, and a production architecture has to choose among them on purpose, often using several at once.
The rest of the article walks that sketch layer by layer: sidecars first, then the edge, then the WASM layer that lets one policy run in both, then how policy and data actually reach every copy, and finally what happens when the data itself lives in more than one region, which is where Sara’s report comes back.
The sidecar pattern
The most common way to decentralize a PDP today is the sidecar: a second process deployed next to every instance of your application, in Kubernetes a second container in the same pod, which your application, or more often a proxy in front of it, calls over localhost instead of over the network.
The usual concrete shape is Envoy plus OPA. Envoy, the proxy most service meshes are built on, has an ext_authz filter that intercepts every request and asks an external authorizer for a decision before passing it to the application. Point that filter at a local OPA (the opa-envoy-plugin packages OPA to speak Envoy’s ext_authz gRPC protocol) and you get authorization enforced in front of every service without the application code ever calling an authorization library. The PEP lives in the proxy, not in the app.
sequenceDiagram accTitle: A sidecar authorization flow using Envoy's ext_authz filter and a local OPA instance accDescr: The diagram shows a request arriving at Envoy, the proxy running as a sidecar container in the same pod as the application. Envoy calls the ext_authz filter, which sends the request to a second sidecar container in the same pod running OPA, over localhost, with no network hop leaving the pod. OPA evaluates the request against policy already loaded into its local memory and returns an allow or deny decision to Envoy in microseconds. If allowed, Envoy forwards the original request to the application container in the same pod. If denied, Envoy returns an error to the caller without the application ever seeing the request. Client->>Envoy: incoming request Envoy->>OPA: ext_authz check (localhost) OPA->>OPA: evaluate policy (in memory) OPA-->>Envoy: allow / deny Envoy->>App: forward (if allowed) Envoy-->>Client: reject (if denied)
This is the library, sidecar or central service question made concrete. A sidecar keeps nearly all of a library’s latency advantage, since a localhost call isn’t a real network hop, while keeping policy out of the application’s own process and language. You don’t need an OPA SDK for every language your services happen to be written in; you need Envoy in front of each one, which is usually already true if you run a service mesh.
Istio and Linkerd, the two most widely used meshes, both build authorization into the mesh itself instead of asking every team to wire up ext_authz by hand. Istio’s AuthorizationPolicy resource lets platform teams declare coarse rules, which services may call which other services and over which paths and methods, enforced by the same Envoy sidecars the mesh already injects for mTLS and traffic routing. That’s the coarse end of the granularity spectrum on purpose: service-to-service and role-shaped decisions, not per-resource ones. A mesh policy answering “can the billing service call the ledger’s /write endpoint at all” and an OPA sidecar answering “can this request write to this specific ledger entry” are two layers, both decentralized, both running on the same sidecar infrastructure. The mesh usually handles the coarse layer, and OPA or an equivalent plugs in through ext_authz for anything finer.
Notice what the sidecar did and didn’t solve. It removed the network hop to a central PDP, but it didn’t remove the need for every copy to agree. You’ve swapped one decider that might be slow or unreachable for many identical local deciders that might drift apart if nobody looks after policy distribution. Keeping potentially thousands of sidecars on the same policy is the central operational problem of every decentralized deployment, and it gets its own section further down.
Authorization at the edge
Take the sidecar idea one step further, past the edge of your own infrastructure, and you get edge authorization: a decision made inside a CDN’s request-handling runtime, Cloudflare Workers, AWS CloudFront Functions or Lambda@Edge, Fastly Compute, before the request has gone any further than the point of presence nearest the user.
The appeal is latency on a scale sidecars can’t match. A user in Singapore hitting an edge location in Singapore gets a rejection in single-digit milliseconds, without the request crossing an ocean to your origin, for traffic that was never going to be allowed anyway: an expired token, a malformed request, a blocked IP range. That matters most for the perimeter layer, the one that’s meant to reject cheaply and fast, and there’s no faster place to reject than before the request has left the user’s continent.
The limits are just as real, and they’re structural rather than a maturity gap that time will close. Edge runtimes are constrained on purpose: short CPU budgets, because they’re billed and scheduled for thousands of tiny invocations rather than one expensive one; little or no direct access to your internal databases; and no sensible way to keep a warm, synchronized copy of a large relationship graph. An edge worker can verify a JWT’s signature and check its claims, which is self-contained, quick perimeter work. It generally can’t run a Zanzibar-style Check that has to walk a graph stored in your origin region, because the round-trip to fetch that data would wipe out the latency advantage that made the edge worth using.
So once the edge is available, resist treating it as a faster copy of your whole authorization stack, the same way the granularity article argued against reaching for fine-grained by default. The edge is the coarsest, fastest layer of a layered design. It rejects what it can and lets everything else through to the layer that has the data to decide properly. An edge worker that tries to replace your origin’s fine-grained PDP either can’t, for lack of data, or ends up caching a stale, disconnected copy of authorization state that drifts from the source of truth. That brings back exactly the staleness risk of the new enemy problem, in the layer with the least visibility into it.
WASM underneath all of it
A sidecar, an edge worker and an application process embedding a policy library are three runtimes with almost nothing in common: different languages, different CPU and memory limits, different ways of deploying. What lets the same policy run correctly in all three without being rewritten three times is WebAssembly (WASM), a portable, sandboxed bytecode format every one of those runtimes can execute.
OPA compiles a Rego policy into a .wasm module with opa build -t wasm, and that one artifact is what gets loaded into a sidecar’s OPA, embedded directly in an application through OPA’s WASM SDKs for Go, JavaScript and others, or run inside an edge worker. Cloudflare Workers in particular supports WASM as a first-class citizen, because its runtime already needed a fast, sandboxed way to execute untrusted, CPU-metered code, and a compiled policy fits that shape well. Cedar has gone the same way, with a reference implementation designed to be embedded. The direction across every engine in this module is the same: compile the policy once and run the compiled artifact anywhere, instead of shipping a full language runtime, a Rego interpreter for example, to every place that needs a decision.
For decentralization this matters because it turns “run policy at the edge” from “port your policy engine to a new, constrained runtime” into “load the artifact you already build for your sidecars into a WASM host the CDN already provides”. That portability is what makes edge authorization practical for anything beyond hand-written token checks.
Policy distribution without a central decider
Every pattern so far shares the problem the sidecar section raised: if no single process makes every decision, how do thousands of independent deciders, sidecars across a fleet and edge workers across dozens of points of presence, agree on what the current policy is?
The answer decentralized architectures converge on is separating authoring from evaluation, which is really the PAP and PDP split from the XACML article stretched over many more PDP copies. A Policy Administration Point stays the single source of truth, the place where policy is written, reviewed, tested and versioned. But instead of every decision calling it live, it publishes immutable, versioned bundles, and every sidecar and edge worker pulls the latest one on an interval, or receives it by push for faster rollout, and evaluates entirely against its local copy.
flowchart LR accTitle: Policy bundle distribution from a central PAP to a decentralized fleet of sidecars and edge workers accDescr: The diagram shows a policy author committing a change, which passes through CI and gets published by the policy administration point as a new signed bundle to a bundle registry. From the registry, two separate groups pull the bundle independently and asynchronously, out of the request path. The first group is a fleet of sidecars, each polling the registry on its own interval and loading the new bundle into local memory. The second group is a set of edge workers at different points of presence, each also pulling and loading the bundle independently. Request-time evaluation at every sidecar and edge worker uses only the locally loaded bundle, never calling the registry or the policy administration point live. Author[Policy author] --> CI[CI: test and sign] CI --> PAP[PAP publishes bundle] PAP --> Registry[(Bundle registry)] Registry -.pull.-> S1[Sidecar fleet<br/>polls on interval] Registry -.pull.-> S2[Edge workers<br/>polls on interval] S1 --> Local1[local bundle,<br/>evaluated offline] S2 --> Local2[local bundle,<br/>evaluated offline]
The trade is deliberate. Evaluation no longer depends on the registry or the PAP being reachable: a sidecar or edge worker with a loaded bundle keeps making correct decisions against that version of the policy even during a network partition, which is a real resilience gain. What you give up is instant consistency. There’s now a propagation window between “policy changed at the PAP” and “every decider is using it”, bounded by each poller’s interval or the push latency, during which different deciders can be correctly evaluating two different policy versions at the same time.
What you also lose, unless you do something about it, is the single place to look. A central PDP sees every decision; a fleet of sidecars sees one pod’s worth each. Most deployments get that back with decision logs: every sidecar and edge worker ships its decisions to a central store even though each decision was made locally. The decision is decentralized; the audit trail, on purpose, isn’t.
Multi-region and multi-cloud
Bundle distribution makes the rules consistent. It doesn’t make the data those rules read consistent, and that problem gets much harder once relationship or attribute data is replicated across regions or clouds instead of living in one database. This is where Sara’s report comes back.
Look at the new enemy problem from the fine-grained authorization article again with this in mind. A single-region Zanzibar-style deployment can pin a Check to a zookie because the write that revoked access and the check that reads the data share, in practice, one consistent store. Once that data is replicated across a US region and an EU region, for latency, serving EU users from EU infrastructure, or for data residency, the GDPR-driven obligation to keep EU personal data in the EU, which relationship tuples about EU users usually are, a revocation written in one region has to travel to the other before a check there can see it. That trip has a floor set by the speed of light and network topology, not by software quality, and no amount of engineering makes it instant.
sequenceDiagram accTitle: The new enemy problem across regions, where revocation must physically propagate over a longer link accDescr: The diagram shows an admin revoking Sara's access in the EU region's data store at time T. That change begins replicating toward the US region's data store over a cross-region link, which takes measurable time bound by network topology. Shortly after time T, a check for Sara's access arrives at the US region, which is closer to the requester, and reads from the US region's replica. Because the EU revocation has not yet arrived over the cross-region link, the US replica still shows Sara with access, and the check incorrectly returns Allow, illustrating that the new enemy problem persists across regions with a propagation delay set by physical distance rather than same-datacenter replication lag. Admin->>EU_DB: revoke Sara's access (T) EU_DB-->>US_DB: replication in flight Client->>US_Engine: Check(sara, viewer, doc) US_Engine->>US_DB: read local replica US_DB-->>US_Engine: still has access (stale) US_Engine-->>Client: Allow (wrong)
No trick makes this go away. What’s left is a choice, made per region and per kind of decision, about which side of the trade you accept, and there are three real options. You can pay cross-region write latency on every sensitive change, requiring a revocation to be acknowledged in every region before the request that made it completes: the strongest consistency and the slowest writes. You can accept a bounded staleness window and write it down as an explicit SLA, the SpiceDB-style choice between at_least_as_fresh and minimize_latency from the fine-grained article, now made per region instead of per request. Or you can partition the data so sensitive relationships never need a cross-region read: an EU user’s access to EU-hosted resources is decided entirely inside the EU region, and the cross-region case never comes up for that data. Most real multi-region deployments mix the last two, partitioning what can be partitioned and accepting a small, monitored, documented window for the rest.
For Sara, the fix the company chose was the third option for most data and the first for one narrow case. US reports live in the US store and are decided there, EU data stays in the EU, and removing someone from a reporting group, because it’s rare and sensitive, waits for acknowledgment from both regions before the admin console says “done”. The administrator now sees a spinner for a second or so. Sara’s bookmark gets a 403.
Everything above about regions applies across clouds too, often more sharply. A bundle registry, a relationship store and a decision-log sink each need a deployment story in AWS, GCP and Azure if your organization runs workloads in all three, and network paths between clouds are usually slower and less predictable than paths between regions on one provider’s backbone. The technical trade-off is the same. What multi-cloud adds is that each cloud is often run by a different team, so “choose a consistency policy per region” becomes as much an organizational negotiation as a technical decision, and it’s better to have that conversation before an incident than during one.
One cashback app, deployed globally
With the EU entity live, the company has to decide, concretely, where each of its authorization decisions runs.
Perimeter checks, whether a request’s token is valid at all, run at the edge in Cloudflare Workers, compiled from the same OPA WASM bundle the company already builds for its sidecars. An invalid or expired token is rejected at the point of presence nearest the user, in milliseconds, and never reaches either region.
Module checks, whether a user’s role lets them open the merchant dashboard at all, run as Istio AuthorizationPolicy rules enforced by the mesh’s own Envoy sidecars. They’re service-to-service and role-shaped, and identical across every pod in both regions, because the mesh configuration is distributed the same way OPA’s bundles are.
Resource checks, whether a specific merchant can edit a specific transaction, run in an OPA sidecar per service against attribute data replicated per region. EU merchant data is partitioned to stay inside the EU, so this kind of decision never needs a cross-region read.
Relationship checks, whether a merchant’s teammate can view a cashback report shared with their team, run against a region-aware OpenFGA deployment. EU teams’ tuples live in the EU store and US teams’ in the US store. The rare cross-region case, a global admin account for instance, explicitly accepts the latency of at_least_as_fresh rather than the cost of fully_consistent, and that’s written down as a known, bounded exception rather than left as a silent gap.
That’s the four granularity layers from the previous article, each placed on purpose at the part of the topology that suits it: edge for perimeter, mesh sidecars for module, service sidecars for resource, a region-aware relationship store for relationship. It’s the point this article has been building towards: granularity and placement are two separate decisions, and a mature architecture makes both for every feature instead of assuming one placement serves every layer.
Testing and running a decentralized deployment
Testing a decentralized system needs something the single-PDP story didn’t: verifying bundle rollouts. A new policy bundle goes to a canary slice of sidecars or edge locations first, its decisions are checked there, and only then does it reach the whole fleet. A bad bundle pushed everywhere at once has no single point where you could have caught it before it took effect. Decision logs shipped from every sidecar and edge worker are what let you compare the canary with the rest.
In production, the number to watch is bundle staleness: how far behind the latest published version each sidecar and edge worker is running, right now, across the fleet. That gap is exactly the window in which different deciders can be evaluating different policy versions. A fleet where it stays small and monitored is a decentralized system working as designed. A fleet where it’s quietly growing, a poller stuck or a push failing in one CDN region, is a system drifting out of sync with itself, which is the failure mode this architecture has to watch for and a single central PDP never had.
Recap
Placement sits above every other authorization decision in this module. Where the PDP runs changes what it can promise, not just how fast it answers, because library, sidecar, central service, edge and multi-region each trade latency, consistency and blast radius differently. Sidecars spread evaluation to every service instance, usually through Envoy’s ext_authz calling a local OPA, keeping most of a library’s speed without tying policy to application code, at the price of a distribution problem across every copy. The edge brings the perimeter layer as close to the user as physics allows, but it can only decide what a constrained, data-poor runtime can decide; it’s a filter in front of your real stack, not a replacement. WASM is the layer that lets one compiled policy run in a sidecar, an edge worker and an embedded library without rewrites. Versioned bundles keep the rules consistent, trading instant propagation for availability, which is the XACML PAP and PDP split stretched over thousands of copies. And once data spans regions or clouds, the new enemy problem becomes a question of physical distance, settled by a deliberate, documented consistency policy per region, as Sara’s bookmark showed.
Three questions to test yourself
- Explain why a sidecar’s latency is close to a library’s even though it involves a second process. What specifically makes a
localhostcall to anext_authzsidecar different from a call to a central PDP over the network? - A team wants to run its full Zanzibar-style relationship Check inside a CDN edge worker to get the lowest possible latency. Using what this article says about edge runtimes, explain why this generally doesn’t work and what should run at the edge instead.
- Describe in your own words why policy bundle distribution is really the PAP and PDP split from the XACML article rather than a new idea, and what is given up, compared with a single central PDP, to gain the resilience of a decentralized fleet.
Hands-on exercises
- Design a sidecar deployment. For a service you know, or a made-up one, sketch an Envoy plus OPA sidecar deployment: what the
ext_authzcall checks, what policy the local OPA needs loaded, and what happens to a request if the sidecar itself is briefly unavailable. - Place a request path on the topology. Take one user-facing action in an application you know (placing an order, viewing a dashboard, editing a shared document) and assign each authorization decision on its path to one of this article’s four places, edge, mesh sidecar, service sidecar or region-aware data store, with a reason for each.
- Design a consistency policy for one cross-region case. Pick one sensitive decision that could plausibly need to work across two regions (a revoked account, a cross-region admin role). Choose one of the three options above, paying cross-region write latency, accepting and documenting a bounded staleness window, or partitioning the data so the case never arises, and justify it against how sensitive the decision really is.
Frequently asked questions
What does 'decentralized authorization' mean here, and how is it different from what the XACML article called a 'distributed PDP'?
The XACML article named three PDP placements, library, sidecar and central service, as a trade-off it left open. This article develops that trade-off in full and adds two placements XACML's era didn't really have: the network edge, and multi-region or multi-cloud deployment. 'Decentralized' here means no single PDP instance sees every request; decisions are made close to where the request already is, by many cooperating instances that share policy and sometimes data, rather than by one central decider every request has to reach.
What is a sidecar authorization pattern, and why put OPA next to every service instance instead of calling one central service?
A sidecar is a second process deployed alongside every instance of your application, usually in the same Kubernetes pod. In authorization it's typically an OPA instance that an Envoy proxy calls over localhost through the ext_authz filter. The reason to do this instead of calling a central service is latency and blast radius: a localhost call has no network hop and doesn't depend on a remote service being reachable, so authorization keeps working even when the rest of the cluster is having a bad day. The cost is that you now run one OPA per pod instead of one service, which is a policy-distribution problem, not a policy-evaluation one.
How does authorization work at the edge (CDN), and what are its limits?
Edge authorization runs a decision inside a CDN's request-handling runtime (Cloudflare Workers, AWS CloudFront Functions or Lambda@Edge, Fastly Compute) before the request reaches your origin. In practice that means token verification and coarse role or claim checks, because edge runtimes are deliberately constrained: short CPU budgets and little or no access to your internal databases or relationship stores. The edge is the perimeter layer of the granularity spectrum, running as close to the user as physically possible. It can't do a fine-grained relationship-graph check and shouldn't try; it exists to reject traffic that never needed to reach your origin.
Why does WASM matter for decentralized authorization?
WebAssembly is what lets the same compiled policy run unchanged in a sidecar, inside a CDN edge worker and embedded in an application process, three runtimes with very different constraints sharing one portable bytecode target. OPA compiles Rego to a WASM module so that 'write the policy once' doesn't turn into 'rewrite the policy for every place it has to run', which would otherwise be the biggest obstacle to decentralizing an authorization engine consistently.
How do you keep policy consistent across many sidecars and edge locations if there's no central decider?
By separating who authors policy from who evaluates it. A central Policy Administration Point still exists and is still the single source of truth, but instead of every request calling it live, it publishes versioned policy bundles that every sidecar and edge worker pulls (or receives by push) on its own schedule and then evaluates locally. That trades instant consistency, every decider seeing every change at once, for availability and latency: deciders keep working correctly with the last bundle they pulled even if the network to the PAP is down. It's the same kind of trade-off the fine-grained authorization article made about data, now applied to policy.
What changes about the new enemy problem when authorization is decentralized across regions?
It gets structurally harder, not just slower. In a single-region Zanzibar-style deployment, a zookie pins a check to a snapshot no older than a given write, and the write and the check can share a consistent view of one data store. Once relationship data is replicated across regions, propagating a revocation with the same freshness guarantee means either paying cross-region write latency on every sensitive change, or accepting that a check served in another region has a real window, bounded by physical distance, where stale data is possible. Better engineering doesn't make that trade-off go away; it becomes a per-region consistency policy you choose and document.