Enforcer
Search that phrase and you get medical billing. Prior-authorisation denial codes, insurance claims, CARC codes. For a question that every engineer building on an authorization service eventually asks, software has written almost nothing.
This page is about the software version: getting an authorization decision that says why, not just no.
A policy decision point is optimised to answer one question fast: may this subject do this action on this resource. The evaluation knows exactly which rule matched and which attribute failed, and then throws that away, because carrying it out of the engine costs work on the hot path and hands policy structure to whoever asked.
So the reason usually ends up in a decision log, if it is kept at all, and the caller gets false.
That is survivable for a web app, where a human sees "403" and opens a ticket. It breaks for AI agents, which do the worst possible thing with a bare deny: retry the same refused call, or tell the user "that failed" with nothing actionable attached.
This is the part that is easy to get wrong, and it is the reason "just return the reason" is not a one-line change.
"Denied: not in Finance approvers" tells whoever asked that a group called Finance approvers exists, that it gates this action, and that membership is the lever. For an internal tool that is fine. For anything reachable by an untrusted caller it is reconnaissance.
What works: a stable machine-readable code the caller can branch on, plus an optional detail string the deployment chooses to send or drop.
An audit log is read months later, and that is where the second mistake lives. A reason that says "allowed because of group membership" stops being answerable the moment the group changes.
The same applies to anything passed in for a single call. Contextual data that exists only for that request has to be recorded with the decision or the record cannot be reconstructed.
The third one is easy to skip and impossible to retrofit. You cannot reconstruct a decision after the fact from data that was never written down.
Reason-in-the-response is genuinely uneven across this category, and in two cases it is an open request rather than a feature.
| Engine | Reason in the response | Notes |
|---|---|---|
| OpenFGA | Not today | Issue #3023, "Log the decision", open since March 2026 |
| Keycloak | Not today | Discussion #45817, custom deny reason in the policy provider, open since January 2026 |
| Open Policy Agent | You build it | Rego can return any document you shape, so the reason is yours to design and yours to maintain. Decision logs are a separate feature |
| Cerbos | Via audit | Ships decision and action audit logs as a documented feature |
| Enforcer | In the response | reason and policy_id come back on the check itself |
Checked 11 September 2026. Both linked threads were open and unanswered on that date. If either has shipped since, that row is stale, and the issue link is the thing to trust rather than this table.
The decision carries the reason and the rule that produced it:
curl -sS -X POST "$BASE/authz/check" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "action": "issue",
"resource": { "type": "payment", "id": "pay_123",
"owner_id": "<owner>", "tenant_id": "<tenant>" },
"contexts": { "amount": 820 } }'
# -> { "success": true, "allow": false, "reason": "..." }
# policy_id comes back only when you named one; it is the policy that decided.
For an agent that is the whole difference. It can tell the user what would make the answer yes instead of retrying a call that will be refused again:
if (!d.allow) return { isError: true, content: [{ type: 'text', text: d.reason }] };
The caller's identity comes from the token, so it is never sent in the body and an agent cannot claim to be someone else. GET /audit-events is the identity and administration trail and does not record authorization decisions, so log the returned reason and policy_id at your call site.
Returning the reason in the response is the right default for agent tool calls and internal services, where the caller is your own code and a useful refusal saves a retry loop. It is the wrong default for an endpoint an untrusted client can hammer, because a reason string is an oracle: enough denials with enough detail map out your policy.
If that is your threat model, send a code, keep the prose in the audit record, and accept that the caller learns less. What you should not do is have neither, which is where most deployments quietly end up.