Cedar vs SpiceDB 🔐
You work on a live shopping platform where sellers run channels, channels host live shows, and buyers watch and buy in real time.
The business wants a better seller experience. Hosts should moderate their own shows. Channel moderators should be able to step into any show on their channel, and seller admins should control everything they own.
You start researching authorization, and two names keep coming up: Cedar[1] and SpiceDB[2].
Cedar looks like the obvious pick. It runs in memory, it is deterministic, its model is formally proven, and there is no network hop.
Then you write the first real policy, and the question changes: who walks the graph?
Same Question, Different Owner
Both tools answer one question: can this principal do this action on this resource?
Cedar is a policy evaluator. You hand it the request, the policies, and every entity they might touch, and it knows nothing beyond that.
SpiceDB is a permissions database modeled on Google's Zanzibar[3]. It stores who relates to what, and it walks that graph for you.
One evaluates facts. The other owns them.
The Example
A show belongs to a channel. A channel belongs to a seller. You can moderate a show if you host it, moderate its channel, or admin its seller.
SpiceDB
You describe that shape once:
definition user {}
definition seller {
relation admin: user
permission manage = admin
}
definition channel {
relation seller: seller
relation moderator: user
permission manage = moderator + seller->manage
}
definition show {
relation channel: channel
relation host: user
permission moderate = host + channel->manage
}
SpiceDB knows the graph because you write it there. Every time you create or update a show, you also store its channel and host:
import { v1 } from '@authzed/authzed-node';
async function createShow(show: Show): Promise<void> {
await db.insertShow(show);
// A second write, separate from the insert above.
// Something has to keep the two in sync.
// That is its own post. Stay tuned.
await spicedb.writeRelationships(
v1.WriteRelationshipsRequest.create({
updates: [
relate('show', show.id, 'channel', 'channel', show.channelId),
relate('show', show.id, 'host', 'user', show.hostId),
],
}),
);
}
Then the caller asks one question and never sees the hierarchy:
async function canModerate(
userId: string,
showId: string,
): Promise<boolean> {
const request = v1.CheckPermissionRequest.create({
resource: v1.ObjectReference.create({
objectType: 'show',
objectId: showId,
}),
permission: 'moderate',
subject: v1.SubjectReference.create({
object: v1.ObjectReference.create({
objectType: 'user',
objectId: userId,
}),
}),
});
const { permissionship } =
await spicedb.checkPermission(request);
return (
permissionship ===
v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION
);
}
Cedar
The policy reads almost the same:
permit (
principal,
action == Action::"moderate",
resource is Show
)
when {
resource.host == principal ||
principal in resource.channel.moderators ||
principal in resource.channel.seller.admins
};
But Cedar can only follow resource.channel.seller if the channel and the seller are in the request[4], so every check starts by loading them:
import { isAuthorized } from '@cedar-policy/cedar-wasm/nodejs';
async function canModerate(
userId: string,
showId: string,
): Promise<boolean> {
const show = await db.getShow(showId);
const channel = await db.getChannel(show.channelId);
const seller = await db.getSeller(channel.sellerId);
// JOIN them in one query? Make the caller pass
// them in? Either way, someone has to load them.
// That is the tricky part, isn't it?!
const answer = isAuthorized({
principal: { type: 'User', id: userId },
action: { type: 'Action', id: 'moderate' },
resource: { type: 'Show', id: showId },
context: {},
policies: { staticPolicies: policy },
entities: [
toShowEntity(show),
toChannelEntity(channel),
toSellerEntity(seller),
],
});
return (
answer.type === 'success' &&
answer.response.decision === 'allow'
);
}
The graph traversal is still there. It just moved into your service.
What Cedar Leaves to You
Cedar delivers fast, correct evaluation. The work around that evaluation falls to you.
Loading the Data
Cedar benchmarks time only the last step, but your users wait for every step:
Evaluation takes microseconds. Each load is a database round trip that waits for the one before it. When you compare Cedar's evaluation to a SpiceDB call, you leave the slowest part out.
The load also grows over time. Cedar needs every entity the policy reads, including the moderator and admin lists on the channel and seller. Each rule that reads one more level adds one more load to every check. Every caller has to fetch it, or the policy denies.
If shows, channels, and sellers live in one database, a join loads them in one round trip. Do it. It works for a fixed chain you own, until you hit scale.
But look at what the join is for. The request only needed the show, and now the join also loads the channel, the seller, and their member lists. It is joining the world just for the sake of authorization.
Every new rule adds another join to every check, pulling in data the request itself never needed. A join also stops working once the data lives in different services, and it cannot list what someone can access.
Scale
Every check reads the same database that serves the shows, and checks spike when a show goes live, exactly when that database is busiest.
You cannot scale authorization on its own, because it has no data of its own. That leaves every check slow or stale.
Loading fresh data on every check avoids stale reads entirely, but every check pays the load above against your main database. When the data is too big to load on every check, you cache it, and now you have written your own consistency model. Zanzibar calls this the "new enemy" problem: remove a moderator, start a show with a surprise drop, and a stale cache still lets them in.
SpiceDB returns a token on every write. If you pass it back on the next check, that check sees the write[5]. If you skip it, the default favors speed over freshness. You choose fresh or fast per check, not once for the whole system.
Availability
Cedar runs in memory, so it never goes down, but the data it needs can. Shows, channels, and sellers may live in different services, and each of them now sits on the path of every check. If the seller service is down, nobody can moderate a show, even though no rule changed.
SpiceDB is a dependency too, but it is one dependency, built and run for that job and designed to be highly available.
Reverse Queries
"Which shows can Alice moderate?" Every dashboard asks this.
SpiceDB answers it with one lookupResources call. Cedar answers one request at a time. You either load every show and check each one, or you use partial evaluation[6], which is still experimental in Cedar. Partial evaluation hands back a residual policy, and turning it into a query is up to you.
What SpiceDB Costs You
SpiceDB isn't free either.
-
Dual writes. Relationships live in SpiceDB, while your source of truth lives in your database. Every change to a show, channel, or seller writes twice, as
createShowdoes above.An outbox closes the gap. In Elixir, that can be an Oban[10] job queued in the same transaction; Temporal[11] is another option. Either way, the relay adds some lag. No token can cover a write SpiceDB has not seen yet, and you have one more moving part.
-
Another stateful system. SpiceDB needs its own datastore. In Go you can embed its check engine in-process[8] and skip the network hop. The embedded engine only answers checks, so writes and
lookupResourcesstill go to a server. It still needs a datastore, external or shared in memory.
When They Are the Same
Count the hops between the request and the facts that decide it.
With zero or one hop, they converge. If the decision rests on token claims or the row you already loaded, Cedar has everything it needs. A rule like "Only verified sellers in the EU can go live after 9 p.m." is Cedar's home turf. SpiceDB supports it first-party with caveats[7]. Reach for them only when you need them.
Some products live entirely in that zone. Take a policy in an API or agent gateway. The caller's token, the route, and the tool it calls all arrive with the request, and nothing waits in a database. That is why AWS puts Cedar in front of agent tool calls in Bedrock AgentCore[9], and Cedar does a great job there.
The limit is the same thing that makes it great. The moment the answer depends on data you have to go load, you are back to walking the graph yourself.
With two or more hops, or any listing, they split. Someone has to walk the graph: either you, in every service, or a system built for it.
They also compose. SpiceDB answers the structural question, and Cedar takes that answer as a fact and handles the conditions.
The Principle
Cedar assumes you already have the facts. SpiceDB assumes the facts are the hard part.
Cedar shines when the request carries everything it needs. Most business rules don't work that way: they depend on who owns what, which lives in a database. So most checks start by loading data, and that load grows with the business.
On a live shopping platform, the channel and the seller decide who can touch a show, and sellers reshuffle their teams all the time. That is exactly the data Cedar would load on every check.
Before you pick, ask: who walks the graph?
References
- [1]
- [2]
- [3]research.google
- [4]docs.cedarpolicy.com
- [5]authzed.com
- [6]docs.rs
- [7]authzed.com
- [8]pkg.go.dev
- [9]
- [10]
- [11]
Talk to you later 🐊 alligator.