So you’ve done the hard work. You’ve decoupled your application logic from your authorization logic, moving your policies to a centralized service. Your codebase is cleaner, and your security posture is stronger. But a new, critical question emerges: how do you actually filter the data you send to users?
You need to ensure that when a user asks for a list of "documents," "projects," or "invoices," they only see the ones they’re truly allowed to see. This brings you to a fork in the road. Do you fetch a broad set of data and filter it in your app, or do you filter it at the source? This is the core dilemma of post-filtering versus pre-filtering.
The most common starting point is post-filtering. It’s conceptually simple:
GET /expenses
.SELECT * FROM expenses;
.Why it's tempting: This approach feels straightforward. The logic is easy to follow, and for a small-scale application or a prototype, it gets the job done without much fuss.
Why it breaks down: In the real world, this model quickly becomes a performance and security nightmare.
LIMIT 10 OFFSET 10
. You might have to fetch 10,000 records just to find 10 that the user is allowed to see on that "page." It’s inefficient and unpredictable.What if, instead of asking for permission for each item, you could ask your authorization service, “What are the conditions for a user to see any item?”
This is the power of pre-filtering. It flips the script entirely:
GET /expenses
.With a stateless authorization engine like Cerbos, this is done via a partial evaluation of your policies. For example, imagine a policy where users can view their own expenses, and managers can view all expenses in their department. If a manager named "sara" in the "engineering" department requests the list, Cerbos would return a query plan containing a filter. That filter would have a condition
object representing the abstract syntax tree (AST) of the rules.
It would look like this:
{
"filter": {
"kind": "KIND_CONDITIONAL",
"condition": {
"expression": {
"operator": "or",
"operands": [
{
"expression": {
"operator": "eq",
"operands": [
{ "variable": "request.resource.attr.ownerId" },
{ "value": "sara" }
]
}
},
{
"expression": {
"operator": "eq",
"operands": [
{ "variable": "request.resource.attr.department" },
{ "value": "engineering" }
]
}
}
]
}
}
}
}
Your application’s data layer can then traverse this structure to translate it into a native SQL WHERE
clause: WHERE ownerId = 'sara' OR department = 'engineering'
. The logic is generated dynamically based on the user and the policies in effect.
The clear wins:
For applications where you absolutely cannot compromise, think high-security or mission-critical systems, there's an even more robust pattern: the hybrid approach.
It’s simple: you do both.
First, you use the highly efficient pre-filtering method with a query plan to fetch the initial dataset from the database. Then, as a final check, you run the results through the authorization PDP one last time before sending the data on its way.
This provides the ultimate safety net. It combines the performance of pre-filtering with the explicit verification of post-filtering, protecting against any subtle misconfigurations or complex edge cases in your policies.
While the initial simplicity of post-filtering is alluring, it’s a solution with a ceiling. It creates performance bottlenecks and security concerns that are difficult to engineer your way out of later.
For modern, scalable, and secure applications, pre-filtering is the clear winner. By pushing the authorization conditions down to the data layer, you build a system that is performant, secure by design, and ready for future growth. And for those who need absolute certainty, the hybrid approach offers truly bulletproof authorization.
If you want to dive deeper, check out Cerbos Hub, join one of our engineering demos or check out our in-depth documentation.
Book a free Policy Workshop to discuss your requirements and get your first policy written by the Cerbos team
Join thousands of developers | Features and updates | 1x per month | No spam, just goodies.