Read authorization attributes from the database, not a copy

AAlex OlivierSeptember 25, 20269 min read
Read authorization attributes from the database, not a copy

The facts an authorization policy needs are rarely new facts. Which plan a customer is on, which team a user belongs to, whether the account is in arrears. All of it already sits in a table somewhere, written and maintained by the service that owns it.

Then a policy needs one of those facts and the copying starts. Plan tier goes into the access token as a claim, so the token issuer acquires a dependency on billing. Team membership goes into a cache that some middleware populates, so there is a warm up path and an invalidation path to own. Clearance level gets pushed into the policy engine on a schedule, so there is a pipeline. Each decision is reasonable on its own. Together they leave the same fact in four places, at four different ages.

The cost is not storage. It is synchronization, and it shows up late. A stale row in a reporting dashboard is a support ticket. A stale attribute on an authorization path is a wrong decision. The contractor deactivated this morning still carries a token issued last night that says they are active, valid, correctly signed, and describing a world that no longer exists. The same argument applies to identity attributes generally, covered in the companion piece on resolving them at decision time.

This is not a local implementation annoyance. It is the part of the authorization stack the standards deliberately left open, and every team solves it in private.

The interface is standardized, the sourcing is not

The industry has largely settled how an enforcement point asks for a decision. The AuthZEN Authorization API reached Final Specification in January 2026 and defines that interface, so any compliant enforcement point can call any compliant decision point. What it deliberately does not define is where the facts come from. The specification observes that many authorization systems are stateless and expect the enforcement point to pass in every attribute the policy needs, and it puts the architecture and state management of the decision point out of scope. That omission is not an oversight, it is the part left to the implementer, and it is where the copying problem lives.

Inside that gap sits a real choice about request shape. An enforcement point can send minimal identifiers and let the decision layer resolve the rest. It can send a fully qualified request with every derived attribute precomputed, which couples the caller to whatever the policy needs this quarter. Or it can send only the instant attributes it alone knows, things like source address, headers and device posture. Most deployments mix all three, so the question is not which is correct but which facts belong in which category. Anything the caller has to look up before it can ask belongs in the first.

The vocabulary for this predates the current wave of tooling. NIST SP 800-162 describes an attribute based system as a decision point, an enforcement point, and a context handler that manages the collection of attributes the decision requires. The context handler is the component most architecture diagrams never name and most teams build anyway, scattered across services. Where it sits matters, because centralizing every attribute can move the bottleneck out of authorization and into the data layer. Its placement is a decision to make alongside where the decision point runs, not after it.

The lookup runs beside the decision, not inside the application

Nobody sets out to maintain four representations of a clearance level. The copies accumulate because, at the moment a check runs, there is no clean way to read the original from application code, and putting the value in the token was easier. Removing them is a plumbing problem rather than a modeling problem. The policy does not change and the attributes it reasons over do not change. What changes is where the value comes from and when.

Cerbos Synapse sits in front of the PDP and speaks the same Cerbos API the application SDKs already use. The application sends a check request carrying an identifier, and before it reaches the policy engine, Synapse fetches whatever the policy needs.

Two pieces do the work. The first is the built in SQL data source, addressed as system://sqldb, which is configuration rather than code. It supports Litestream, MySQL, Postgres and SQLite. You give it a name, a connection string and connection pool settings. It then accepts a query with named parameters, binds the values through the driver, runs it and hands back the result as JSON. One row becomes an object keyed by column name, several rows become an array, and no rows becomes null.

The second piece is a proxy extension, which intercepts CheckResources and PlanResources requests on their way to the PDP and can modify them. A worked version reads the principal identifier off the request, calls the data source with a single query joining the employee row to their project memberships, and writes the result onto principal.attr. The policy reads those columns as ordinary attributes, so a resource policy condition comparing a department or checking project membership looks exactly as it would if the application had sent those values itself. This is externalized authorization applied to the data rather than to the decision.

That extension can be written in Starlark, a small Python dialect Synapse evaluates directly. The whole thing runs to a few dozen lines with no build step and no binary to ship. That matters more than it sounds. When adding a lookup means a pull request against a Go service, wiring a driver and cutting a release, teams find ways to avoid it, and the copies stay. Extensions can also be native Go or WASM when the logic warrants it, the route the companion piece on relationship data takes.

Cache TTL is a correctness decision

Reading the database on every check is only viable with a cache in front of it, and this is where the design stops being routine. Synapse has a cache backend that is either in process or backed by Redis. An extension can manage entries directly, or pass cache options with the lookup and let the data source handle it. Either way the extension sets a key and an expiry.

The temptation is to treat the TTL as a performance dial and turn it up until database load looks comfortable. It is not a performance dial. The TTL is the maximum age of a fact at the moment a decision is made, which makes it the window in which a revocation has happened and the policy does not yet know. Five minutes for a department name is unremarkable. Five minutes for an account suspension flag is five minutes of access somebody has already decided to take away. Those two facts do not belong under the same key with the same expiry.

Cache scope does more work than it appears to. A single CheckResources call can carry a batch of resources, and the enrichment runs once per request rather than once per resource, so a screen asking about forty documents costs one lookup. When a request produces several checks in sequence, an edge check followed by a record level check inside the service, a shared cache collapses those into one entry. The in process backend keeps that entry local. Redis shares it across the fleet, raising the hit rate and lengthening the staleness window for everyone at once.

Authorization context assembled in one place instead of every service

The request shrinks to a minimal identifier and the context handler becomes a real component instead of an implied one. The application does not assemble a principal, does not hold a connection to the directory or the billing database, and does not need a release when a policy starts depending on a new column. That removal is most visible across an estate of microservices, where the same enrichment code otherwise gets written once per service, in whatever language that service happens to use, with subtly different null handling in each copy.

Having it in one place changes what a change costs. Adding an attribute is a config and script edit rather than a coordinated deployment across every service that performs a check, and the blast radius of getting it wrong is one component you can roll back.

Every attribute that influences a decision now flows through one component, so it can be measured and logged in one place. Paired with decision logs, why a request was denied has a single path to walk. The same enrichment applies to PlanResources, so the query plan a service turns into a database filter is built from the same attributes as the checks.

What this couples, and what happens when it fails

Putting a database query on the authorization path means decision availability now depends on database availability. That is the information point bottleneck in its most literal form. If the primary is under load, saturated on connections or mid failover, checks are affected. Connection pool settings are the first line of defense, because an authorization layer that opens an unbounded number of connections during an incident makes the incident worse. Cap the pool and size it against what the database can spare, not what the gateway would like.

Failure behavior is a deliberate choice with two defensible answers. A proxy extension can be marked as required, in which case a failed lookup terminates the chain and the caller gets an error. Left optional, the chain continues and the request reaches the PDP without the attribute, and because Cerbos denies by default, conditions referencing a missing attribute do not activate their rules. The check fails closed, the same posture AuthZEN takes when an evaluation cannot complete.

An internal tool that should stop dead when the database is unreachable wants the first behaviour. A read path where a denial means a broken page for a legitimate user may prefer the second with an explicit fallback rule in policy. What is not acceptable is inheriting whichever the default happened to be.

Germany's federal API authorization infrastructure treats availability as a design requirement rather than a tuning exercise. Its published blueprint is open source, and an analyst account of it records that the whole thing is designed so that a decision point keeps working even when the central plane is down. Read that way, the cache is not there to save a round trip. It is what lets the authorization path degrade instead of stopping, another reason its expiry belongs in the design.

Read replicas are worth considering before the primary, though replication lag stacks on top of cache TTL rather than replacing it. Queries on this path should be indexed and narrow, and the lookup runs on every cache miss, which under a cold cache is every request.

Closing

The argument here is smaller than it looks. It is not that databases are a better source of authorization context than identity providers or graph stores, which is why Synapse treats all of them as data sources. It is that the standards settled how the question gets asked and left open where its inputs come from, and a fact should be read from the system that owns it rather than copied ahead of time into somewhere more convenient. Copies are cheap to create and expensive to keep honest, and the expense is paid in decisions that were right yesterday.

The field reference for the SQL data source, the proxy extension lifecycle and the cache configuration is in the Synapse documentation, and the wider picture is in the launch post.

Try Cerbos to see how this works in practice, or book a call to talk through your architecture with the team.

Go deeper: How to adopt externalized authorization (eBook) for moving decision logic and the data it depends on out of application code

FAQ

Free policy workshop

Get your first Cerbos policy written by our team.

Book a session to talk through your requirements and walk away with a working policy.

Book a session