SpiceDB CheckPermission: How Schema Tuples Decide a Request
CheckPermission answers one question: does this subject have this permission on this resource? A worked schema, tuple writes, the rewrite-tree mechanism, and the mistakes that produce wrong answers.
29 Dec 2025, 17:52 UTC

What CheckPermission actually answers
SpiceDB's CheckPermission answers one narrow question: does this subject have this permission on this resource, right now, according to the schema and the relationship tuples stored in the datastore? It is not a query language and it does not return a list. It returns a permissionship — effectively yes, no, or, with caveats, "yes if you supply context."
The practical consequence: if you want to filter a list of documents down to the ones a user can view, calling CheckPermission once per document is the wrong tool. Use LookupResources for that. CheckPermission is for the single gate — the moment before you serve one object or accept one write.
A worked example: schema, tuples, check
Version assumption: the v1 authzed.api.v1.PermissionService API and a recent zed CLI. Older SpiceDB releases exposed the same call as Check, and pre-v1 clients use different field names. Run zed permission check --help to confirm argument order for your build.
1. Define the schema
definition user {}
definition document {
relation reader: user
relation writer: user
permission view = reader + writer
permission edit = writer
}
Two stored relations (reader, writer) and two computed permissions. Relations hold tuples; permissions hold expressions. That distinction is the whole design.
2. Write the relationship tuples
zed relationship create document:budget-q3 writer user:alice
zed relationship create document:budget-q3 reader user:bob
Each tuple is a triple: resource, relation, subject. The canonical string form you will see in logs and CLI output is document:budget-q3#writer@user:alice.
3. Call CheckPermission
zed permission check document:budget-q3 edit user:alice
Expect a true result for alice on edit, and a false result for bob, who only holds reader. Over gRPC the request is a field sketch like this:
resource: { object_type: "document", object_id: "budget-q3" }
permission: "edit"
subject: { object: { object_type: "user", object_id: "alice" } }
consistency: { minimize_latency: true }
The permission field accepts a relation name too. Checking writer directly works, but it couples your application to the schema's internal shape — see the mistakes section.
4. Verify the tuple persisted
Before blaming the check, confirm the data. Read the relationships back for the resource:
zed relationship read document:budget-q3
If the tuple is missing, the check is correct and your write path is wrong. This ordering — read the data, then re-run the check — resolves most "SpiceDB is broken" reports faster than re-reading the schema.
What the server does between request and answer
The server starts at the resource's permission expression and evaluates it as a rewrite tree. Union (+) succeeds if any branch succeeds; intersection and exclusion combine branches; an arrow walks from one object to a related one before evaluating the rest. Each leaf is a tuple lookup against the datastore.
Every hop is a real read. A permission defined as view = reader + writer on a single document is cheap. A permission that walks folder hierarchies, then group memberships, then nested groups is a chain of reads whose cost grows with depth and fan-out.
Consistency is part of the request, not a global setting. minimize_latency lets the server serve from a possibly stale snapshot. If you write a tuple and immediately check it, pass the ZedToken returned by the write using at_least_as_fresh:
consistency: { at_least_as_fresh: { token: "<zedtoken from the write response>" } }
Without that, a read-your-own-write test can fail intermittently and look like a schema bug.
Limits worth knowing before you design
- Resolution depth. The server enforces a maximum traversal depth, configurable at startup. A schema that recurses through long group chains can hit it and return no permission rather than an error, which is easy to misread.
- Fan-out. A relation with thousands of subjects makes each check touching that relation expensive. Projections — precomputed relations written by your application — trade write cost for predictable read cost.
- Caveats. A caveated tuple can produce a conditional permissionship. Your client must evaluate the caveat with the request context; treating conditional as true is an authorization bug.
- Wildcards. A wildcard subject such as
user:*on a relation can make a check succeed for a user you never wrote a tuple for. When debugging an unexpected yes, look for wildcards first. - No subject validation. SpiceDB does not require
user:aliceto exist anywhere. A check for a nonexistent user returns no permission, not an error.
Common mistakes
- Checking relations instead of permissions. Applications that call check with
writerbreak the moment you renamewriter. Call the permission and let the schema absorb the rename. - Renaming a relation without migrating tuples. A schema write replaces the schema, but it does not rewrite stored tuples. After renaming
readertoviewer, every existingreadertuple is orphaned and checks againstviewerreturn no permission until you rewrite the data. - Filtering lists with N checks. Use
LookupResources; it is one round trip and the server does the traversal once. - Ignoring the write's ZedToken. Covered above, and the most common source of flaky tests.
Rollback and cleanup
Writing tuples changes state, so plan the undo. To remove a single grant:
zed relationship delete document:budget-q3 writer user:alice
Schema changes are a full replacement, not a patch: re-writing the previous schema text restores the previous definitions. That rollback does not restore tuples that were deleted, and it does not clean up tuples left behind by a renamed relation — those need an explicit read-and-rewrite pass.
How to check your own setup
- Start a throwaway server (
spicedb serve-testingruns an in-memory instance) and write your schema. - Insert one tuple per relation you care about.
- Read the tuples back and confirm the string form matches what you intended.
- Run the check for a subject that should pass and one that should fail. Both results matter; a check that returns true for everyone usually means a wildcard or a union branch you forgot.
- Re-run the passing check immediately after a write with
at_least_as_freshto confirm your consistency handling.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.