Most authorization interfaces return a boolean. An enforcement point asks whether a subject may perform an action on a resource, and it gets back true or false. That framing is common to most authorization models. It is easy to implement, easy to test, and it covers a large amount of real policy. It does not cover all of it.
Think about a refund above a threshold that needs a manager to sign off on it. A production change that needs a second approver. A bulk export of customer records that needs a fresh authentication factor first. None of these actions are forbidden. Each is not permitted under the conditions holding right now, and in each case a specific, nameable event would change the answer. Collapsing that into false discards the only part of the decision the caller could have acted on.
A deny that says nothing pushes authorization policy back into application code
The distinction that matters is not between allow and deny. It is between a denial that carries what would unblock it and a denial that carries nothing.
When a denial carries nothing, the calling system has to work out why on its own. It has to know that refunds over a certain amount go to a manager, that production changes need a second approver, that exports need step up. That knowledge is policy, and it now lives in application code, duplicated across every service that handles a rejection. Two implementations drift. A threshold moves in the policy and stays stale in the service written first.
When a denial carries what would unblock it, the calling system needs none of that. It reads a structured value off the response and routes to the workflow the policy named. The rule stays in one place, the workflow stays where workflows belong, and the enforcement point holds no policy logic of its own. This is the argument for externalizing authorization applied to the part of the decision that usually gets thrown away.
Cerbos policy outputs return a payload alongside the allow or deny
Cerbos rules can carry an output block, a CEL expression evaluated during policy evaluation whose result is returned in the API response next to the effect.
- name: refund-approval-threshold
actions: ['issue']
effect: EFFECT_ALLOW
roles: ['support_agent']
condition:
match:
expr: R.attr.amount <= P.attr.refundLimit
output:
when:
conditionNotMet: |-
{"requirement": "manager_approval",
"limit": P.attr.refundLimit,
"workflow": "refunds.escalation"}
Two triggers exist. output.when.ruleActivated fires when the rule matched fully, meaning the action, the roles and any derived roles matched and the condition was satisfied. output.when.conditionNotMet fires when the action and roles matched but the condition evaluated false. The second trigger is the interesting one here, because it is precisely the state where a rule applies to the caller and the caller has not met the bar yet.
The value arrives in an outputs array on the result, each entry tagged with src naming the policy and the rule that produced it, and val holding the expression result.
"outputs": [
{
"src": "resource.refund.vdefault#refund-approval-threshold",
"val": {
"requirement": "manager_approval",
"limit": 500,
"workflow": "refunds.escalation"
}
}
]
Because the source is tagged, an enforcement point evaluating several rules across several resource policies can tell which requirement it is looking at. The outputs documentation covers the rest, including the caution that heavy use of output expressions costs evaluation time.
Policy outputs are informational and never change the authorization decision
One constraint shapes every integration built on this mechanism, so state it plainly. An output does not modify the effect. A deny with an output attached is still a deny, and the enforcement point has to honor it.
That is a design choice rather than a gap. If a returned value could soften a denial, the decision would stop being unambiguous and the audit record would stop being a straight answer. Keeping outputs off the decision path means the log says deny, and the reason sits beside it where nobody can mistake it for the answer. Audit records stay legible.
The cost lands on the enforcement point, which has to read the output rather than treat the boolean as the whole response. Returning structured data alongside a decision is covered generally in our earlier piece on context aware authorization. What is worth separating out is this narrower use, where the value is not an explanation for a human but an instruction for a machine about which process to start.
The AuthZEN ARAP profile standardizes the requestable denial
The AuthZEN working group has a draft profile for exactly this shape. ARAP, the AuthZEN Access Request and Approval Profile, extends the AuthZEN Authorization API so that a decision point can return a denial marked as remediable, carrying where and how to request access.
The wire format is pinned down in a way hand rolled versions never are. The denial carries an access request endpoint, an expiry after which the request can no longer be raised, a binding token tying a later submission back to the evaluation that produced it, and an evaluation identifier for the audit trail. The enforcement point submits to that endpoint, and on approval the service returns an approval object with an identifier, an expiry, and optional proof material.
Be clear about status. ARAP is Standards Track Draft 1, dated July 27, 2026, authored by K. McGuinness. Nothing implements it today and it may change before it stabilizes. The reason to read it now is that it names a shape teams are already building by hand, usually differently in each service. The base Authorization API is a separate document and further along, having reached Final Specification in January 2026. We have written elsewhere about what it standardizes and where Cerbos stands against it.
An approval is an input to a new authorization decision, not a permission
The design decision that matters most is what happens after an approver says yes. The enforcement point does not treat the approval as permission. It issues a fresh evaluation carrying the approval as an input. The profile is direct about this. The enforcement point "MUST include the approval object unchanged at context.approval inside the AuthZEN Authorization API re-evaluation request."
The decision point evaluates that new request against current policy, with the approval as one more attribute. It can still deny. Policy may have changed, the subject's employment status may have changed, risk signals may have moved. Nothing is quietly promoted into a permission that outlives the request that prompted it, and the decision point stays authoritative throughout. NIST describes the same separation in SP 800-162, where the decision point, the enforcement point and the context handler that assembles attributes are distinct components.
In Cerbos terms the approval is another attribute in the request, readable in a condition like any other. If it arrives as a signed token, aux data exposes its claims to the policy without the caller unpacking them first, though the docs are explicit that PDP side verification is a precaution against tampering in flight, not the authoritative check. The rule consuming an approval sits in the same file as the rule that produced the requirement, so both halves get reviewed together.
An approval frequently produces a grant that is delegated and time bounded rather than permanent, a related pattern that deserves separate treatment. The point here is narrower. An approval is evidence presented to a decision, not a decision made elsewhere.
Approval workflows belong in the workflow engine, not the policy engine
Approvals already have a home in most organizations. A ticketing system, an access request tool, a chat based flow, a change management process. The policy's job is to state what is required, not to run the process or know who the approvers are.
That separation is load bearing. Once a policy engine holds approver lists, escalation paths, reminder schedules and request state, it is a workflow engine with a policy language attached, and inherits the availability and durability requirements of one. Keeping policy design free of that state is what makes a decision point cheap to run next to every service.
The two systems meet at attribute lookup. The policy needs to know whether an approval exists and what it covers, and that lookup sits on the request path, which puts latency and cache behavior in scope. Cerbos Synapse covers this seam with cached data sources in front of the PDP, so an attribute fetch does not become a per request call to a ticketing API.
Three failure modes to design against when a denial carries a requirement
An output the caller ignores is unenforced policy. The effect is honored, so nothing looks broken, but a requirement the policy stated has no bearing on behavior. On a denial it means a user stuck at a dead end. On an allow carrying an obligation, such as recording the access or prompting for a second factor, it means an obligation nobody performed. Treating outputs as optional decoration is one of the blind spots that surfaces during an audit rather than during development.
An approval that is cached and reused becomes the standing grant the design was avoiding. If the enforcement point stores the approval object and replays it against later requests, the expiry and the fresh evaluation both stop meaning anything. Approvals should be scoped to the request that prompted them, and the decision point should be able to tell a first presentation from a replay.
A denial that explains itself too well is an information disclosure. "Requires approval from the account owner" tells an unauthorized caller that the resource exists and who owns it. Reason codes need the same review as any other response content, which usually means a stable machine readable code plus a display string chosen for the audience.
What changes in the policy and what changes in the enforcement point
The change is small in the policy and larger in the enforcement point. In the policy it means writing a conditionNotMet output next to any condition a caller could plausibly satisfy later. In the enforcement point it means reading the whole result rather than the effect, and having somewhere to send a request when the policy names one.
What comes out is a system where the rule about who approves what exists once, in a file that can be reviewed and tested, and every service behaves the same way without knowing anything about approvals. The decision stays binary, which keeps the audit trail honest, and the path out of a denial stops being something each team reconstructs from memory.
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 a step by step playbook on moving policy out of application code
FAQ
Tagged in




