Zanzibar Demystified: How Google Answers 'Can This User Do This?'
July 18, 2026 · 13 min read · Lire en français
Contents
Every application ever written has to answer one question, over and over: can this user do this thing to that resource? Can Alice read this document. Can Bob delete this comment. Can this service call that endpoint. It feels like the most boring line of code in the building, a single if.
Then the product grows. The document lives in a folder, and folder access should cascade. The document is shared with a team, and the team has sub-teams, and people join and leave. Someone in support needs to answer "why can Bob see this file?" and nobody can, because the answer is smeared across six services each with its own if. Now a user is removed from a group and, for the next thirty seconds, a stale cache somewhere still says yes. That is not a boring if anymore. That is a distributed-systems problem wearing a boolean's clothing.
Google hit this at a scale nobody else had: one authorization question that Docs, Drive, Photos, Calendar, YouTube and Cloud all needed to ask, correctly, billions of times a day, in under ten milliseconds, everywhere on Earth. Their answer was Zanzibar, described in a 2019 paper that quietly became the most influential authorization design of the decade. This walks through what it actually is, why each hard part is hard, and how you can use the same model today through open-source heirs like OpenFGA without running Google's infrastructure.
The question that looks simple and is not
Start with the version everyone writes first. Permissions live next to the resource, and you check them inline.
func canView(user User, doc Document) bool {
if doc.OwnerID == user.ID {
return true
}
for _, id := range doc.SharedWith {
if id == user.ID {
return true
}
}
return false
}
On day one this is fine. The trouble is not that it is wrong, it is that it does not compose, and authorization is all about composition:
- Inheritance. A document in a folder should inherit the folder's viewers. Now
canViewneeds to know about folders. Folders can nest, so it needs recursion. - Groups. Share with a team, not a list of user IDs. Teams contain teams. Now
canViewwalks a group graph. - Duplication. Docs, Drive and Photos each grow their own copy of this logic, and they drift. A permission bug in one is not a bug in the others, which is worse, because you cannot reason about the system as a whole.
- Audit. "Why does Bob have access?" has no answer, because access is a computation scattered across services and rows, not a fact you can look up.
- Consistency. You revoke Bob and immediately change what he was looking at. If a cache serves the old answer for even a moment, Bob sees content he should not. The paper calls this the "new enemy" problem, and it is the whole reason Zanzibar is complicated.
RBAC (roles) and ABAC (attributes) each solve part of this and neither solves the shape of it, because the real shape is a graph: users relate to groups, groups to folders, folders to documents, and a permission check is a question about reachability in that graph. That reframe is the entire idea behind Zanzibar.
The core idea: permissions are relationships
Zanzibar stores authorization as a single, uniform kind of fact called a relation tuple. Read one out loud and it is an English sentence:
document:readme#viewer@user:alice
"On object document:readme, the viewer relation includes user:alice." Alice can view the readme. Every permission in the entire company reduces to a pile of these tuples of the form object#relation@subject. There is no per-product schema of columns and join tables; there is one table of relationships, and a permission check is a graph query over it.
This is ReBAC, relationship-based access control. The insight is that "role" and "attribute" are both just special cases of "relationship." Owner is a relationship. Membership is a relationship. Parent folder is a relationship. Once everything is the same kind of edge, one engine can answer every question, and the audit story falls out for free: "why does Bob have access?" becomes "show me the path of tuples from Bob to this document," which is a query, not an archaeology dig.
The subject on the right of the @ can itself be a set of users, not just one, and this is where the model gets its power. Instead of naming Alice, point at everyone who is a member of a group:
document:readme#viewer@group:engineering#member
"Everyone in the member relation of group:engineering is a viewer of the readme." This is a userset: a reference to "the members of this relation on this object." Usersets are how groups, nesting and inheritance all collapse into the same tuple format. A group with sub-groups is just a tuple whose subject is another group's member userset. The graph builds itself.
Modeling it with OpenFGA
Zanzibar itself is internal to Google, but its model is now an open standard you can run. OpenFGA (a CNCF project, born at Auth0/Okta) is the most widely used implementation, and its DSL makes the model concrete. A model has types (the kinds of objects), and each type declares relations.
Start with the simplest useful model: documents with three roles, where each role implies the weaker one.
model
schema 1.1
type user
type document
relations
define owner: [user]
define editor: [user] or owner
define viewer: [user] or editor
Read the rewrite rules on the right. editor is "anyone directly assigned editor, or anyone who is owner." viewer is "anyone directly assigned viewer, or anyone who is editor." So owners are editors are viewers, for free, with no duplicated tuples. That or is a set union, and it is the same union Zanzibar's config calls a userset rewrite. The engine computes it at check time; you never store the derived tuples.
Now the actual permission facts, the tuples:
[
{ "user": "user:alice", "relation": "owner", "object": "document:readme" },
{ "user": "user:bob", "relation": "viewer", "object": "document:readme" }
]
Alice owns it, Bob can view it. A check reads:
check(user:alice, viewer, document:readme) → allowed: true // owner ⇒ editor ⇒ viewer
check(user:bob, editor, document:readme) → allowed: false // only a direct viewer
Alice is a viewer even though no tuple says so, because the model derives it. That is the whole trick, made small.
Groups, without ever touching the document again
Add a group type and let a document be shared with a group's members. This is the userset from earlier, in OpenFGA syntax.
type group
relations
define member: [user]
type document
relations
define owner: [user]
define editor: [user] or owner
define viewer: [user, group#member] or editor
The [user, group#member] says the viewer relation accepts either a direct user or the member userset of a group. Now share with the whole engineering team in one tuple:
[
{ "user": "group:engineering#member", "relation": "viewer", "object": "document:readme" },
{ "user": "user:carol", "relation": "member", "object": "group:engineering" }
]
Carol can now view the readme, and you never touched the document to grant it. Add someone to group:engineering#member and they gain access to every document that group can see, instantly and in one place. Groups can contain groups the same way, by making a group's member accept group#member, and the check just walks the chain.
Inheritance: the folder that shares its access down
The last core move is tuple-to-userset, Zanzibar's name for "follow a relation to another object and inherit its relation." It is how a folder's viewers become the viewers of every document inside it. OpenFGA spells it X from Y.
type folder
relations
define viewer: [user, group#member]
type document
relations
define parent: [folder]
define owner: [user]
define editor: [user] or owner
define viewer: [user, group#member] or editor or viewer from parent
The new clause viewer from parent means "also anyone who is a viewer of the object in my parent relation." Wire a document to a folder with one tuple and the folder's entire viewer set flows down:
[
{ "user": "folder:handbook", "relation": "parent", "object": "document:readme" },
{ "user": "user:dave", "relation": "viewer", "object": "folder:handbook" }
]
Dave can view the readme because he can view its parent folder. Nest folders inside folders and access cascades the whole way down, evaluated on demand. Three primitives, union (or), intersection (and, for "must be both"), and tuple-to-userset (from), are enough to express essentially every real-world permission scheme. That economy is why the model has held up.
The genuinely hard part: consistency at global scale
Everything so far is a clean data model. It would also be useless at Google's scale without solving the problem the model quietly created: this graph is enormous, sharded across the planet, cached everywhere, and read billions of times a day. You cannot re-walk the full graph on every check. So you cache. And the moment you cache authorization, you inherit the new enemy problem.
Picture it. Alice removes Bob from a document, then edits it to add a secret. Two writes, in that order, with a causal link between them: she revoked because she was about to add the secret. If a replica somewhere is a few seconds stale and still thinks Bob is a viewer, Bob reads the secret. The individual answers were each "correct" for some past instant, but serving them out of order violated the causal ordering the user relied on. Stale reads are usually a performance footnote. In authorization they are a security breach.
Zanzibar's fix is a snapshot token it calls a zookie. When the ACL changes, the caller gets back a zookie, an opaque token encoding a timestamp. When the application later asks "can Bob view this?", it passes the zookie that corresponds to the content it is about to show. Zanzibar then guarantees the check is evaluated at a snapshot at least as fresh as that token. It is allowed to read from a cheap, slightly stale replica, but never one older than the content being protected. The rule in one line: the permission check must never be staler than the data it guards. Zookies make "fresh enough" precise instead of hoping every replica is current.
Underneath, this leans on Spanner and its TrueTime clock, which gives Zanzibar globally-ordered timestamps and external consistency: a write that finished before another really does get an earlier timestamp, everywhere, without a global lock on the read path. Snapshot reads at a bounded-staleness timestamp are what let Zanzibar serve most checks from local replicas in single-digit milliseconds while still refusing to serve a dangerous stale answer. The consistency is not free, it is bought with TrueTime, and that dependency is a big part of why the paper reads like distributed-systems research and not a CRUD tutorial.
The other scaling trick worth knowing by name is Leopard, an index that pre-flattens deeply nested group membership. Some groups nest dozens of levels deep with millions of members, and walking that live on every check would blow the latency budget. Leopard maintains a denormalized, incrementally-updated set representation so a "is X a member of this giant nested group?" question becomes a fast set lookup instead of a deep graph traversal. It is the specialized cache that keeps the general model fast on its worst-shaped inputs.
The numbers, so the complexity makes sense
It is easy to read all this and think it is over-engineered. The scale is why it is not. From the paper: Zanzibar serves more than 10 million authorization checks per second, keeps over 95% of them under 10 milliseconds (and 99% under a small tens-of-ms bound), holds trillions of relation tuples across more than 1,500 servers in dozens of clusters worldwide, and has run at greater than 99.999% availability for years. Every one of the hard parts, zookies, Spanner, Leopard, exists to hold one of those numbers while not breaking another. Consistency without the latency. Latency without stale security. Scale without either.
You do not need Google to use this
You will almost certainly never run Zanzibar. You do not need to. The model outlived the paper as a family of open-source engines you can deploy this afternoon:
- OpenFGA (CNCF, from Auth0/Okta), used throughout this article. Friendly DSL, HTTP and gRPC APIs, SDKs in most languages.
- SpiceDB (AuthZed). The most faithful-to-the-paper implementation, with a real zookie equivalent it calls a ZedToken and tunable consistency modes.
- Ory Keto, an earlier Go implementation of the tuple model.
The consistency knob shows up in these tools directly. OpenFGA lets a Check request pass a consistency preference: MINIMIZE_LATENCY reads from cache and is the default, while HIGHER_CONSISTENCY forces a fresh evaluation, the same trade-off zookies encode, exposed as a per-call choice. SpiceDB's ZedToken is the closer analog: you store the token you get from a write and pass it on later checks to demand "at least this fresh."
A pragmatic way to adopt it: keep your app's data where it is, and move only the authorization decision into the ReBAC engine. On each write that changes access, push a tuple. On each check, ask the engine. You get the single source of truth, the audit path, and the inheritance model without rewriting your database.
RBAC, ABAC, ReBAC: when to reach for which
ReBAC is not automatically the answer. Match the model to the shape of your permissions.
| Model | Answers with | Shines when | Struggles when |
|---|---|---|---|
| RBAC (roles) | "What role does the user have?" | Small fixed set of roles, flat resources | Per-object sharing, inheritance, "who can see X?" |
| ABAC (attributes) | "Do the user/resource attributes satisfy a policy?" | Rules over context (time, region, clearance) | Relationships and nesting; audit of why |
| ReBAC (relationships) | "Is there a path of relations from user to object?" | Sharing, groups, folders, hierarchies, per-object grants | Purely attribute rules with no relationships |
Most real systems are a blend, and the good news is ReBAC engines increasingly let you attach conditions (OpenFGA has conditional tuples, contextual and-style checks) so you can fold a bit of ABAC into the graph when you need "editor and only during business hours."
Takeaways
- Authorization is a graph problem in disguise. The moment you have sharing, groups or inheritance, "can this user do this?" is a reachability question, and modeling it as scattered
ifstatements is why it rots. - One tuple format expresses everything.
object#relation@subject, with usersets and rewrite rules, collapses roles, groups, nesting and inheritance into a single uniform model an engine can reason about and audit. - Union, intersection and tuple-to-userset are the whole language.
or,and, andfromin OpenFGA cover essentially every permission scheme you will meet. - Caching authorization creates the new enemy problem, and zookies solve it. The check must never be staler than the data it guards. Zanzibar buys that guarantee with Spanner's TrueTime; SpiceDB's ZedToken and OpenFGA's consistency modes expose the same trade-off to you.
- You can have the model without the infrastructure. OpenFGA, SpiceDB and Ory Keto put Zanzibar's design in reach today. Move the decision, not your data, and you get a single source of truth and a real answer to "why does Bob have access?"
Zanzibar's lasting lesson is not the ten million checks a second. It is the reframe: stop treating permission as a boolean you compute in a hurry, and start treating it as a relationship you store, so that the answer to every access question is a fact you can look up, explain and trust.
Subscribe to future posts
Get future posts in your inbox. No spam, unsubscribe any time.
Related posts
From RBAC to ReBAC: Migrating a Role System to OpenFGA Without Downtime
Roles work until someone says 'share just this document with just this person.' That is the day RBAC runs out of road. This is the practical migration: mapping role tables to relation tuples, running OpenFGA in shadow next to your SQL checks, backfilling safely, and only then unlocking the per-object sharing and hierarchy that roles never could. With the traps nobody warns you about.
July 19, 2026
Load Balancing Algorithms: Each One Fixes the Last One's Flaw
Spreading requests across N servers sounds like a one-liner: pick a server, send the request. Then one backend is slower, or bigger, or holds a session, and the naive choice falls apart. This walks the classic algorithms as a chain where each one exists to fix the previous one's blind spot: round robin, weighted, least connections, power of two choices, and consistent hashing, in Go, ending with the failure every tutorial forgets.
July 20, 2026
Retry Storms: How Good Clients Take Down Healthy Servers
A retry looks harmless: the request failed, so try again. Multiply that by every client, add one slow dependency, and retries turn into a self-inflicted DDoS. This walks from the naive retry loop to exponential backoff, jitter, retry budgets and circuit breakers, the caller-side half of resilience that pairs with rate limiting on the server.
July 11, 2026