Security teams spend a lot of time deciding whether a request should be allowed. The person making that request experiences the decision through a much smaller interface: usually a status code and a sentence.
This is Part 3 of Security Infrastructure in Practice, a series about what happens when security design meets production systems.
A user tries to export a report and receives:
403 Forbidden
The service is technically correct. It is also about to create a support ticket.
“Access denied” does not tell the user whether the request violated policy, an approval expired, or an identity dependency failed. Those cases may produce the same HTTP status. They do not have the same remedy.
Authorization errors are part of the product interface. Treating them as an afterthought makes secure systems harder to use and harder to operate.
Separate Effect From Reason
The enforcement effect is usually small: allow or deny. The reason needs more structure.
{
"effect": "deny",
"reason": {
"code": "APPROVAL_EXPIRED",
"message": "The approval for this export has expired.",
"next_step": "Request a new export approval."
},
"decision_id": "dec-9f31c2"
}
The code is stable for software. The message can change without breaking clients. The decision identifier connects the user-facing error to protected diagnostics.
Do not make clients parse prose. Someone will build a workflow around the exact punctuation.
Unknown Is Not the Same as No
Suppose a device posture service times out. The authorization layer cannot confirm that the device meets policy.
Failing closed may be correct for the operation. Returning DEVICE_NOT_COMPLIANT is still wrong because the system did not establish noncompliance.
Use a distinct reason:
DEVICE_STATUS_UNAVAILABLE
That tells the user to retry or contact the service owner. It tells operations to inspect the posture dependency. It avoids creating a false security finding against the device.
Give Different Readers Different Detail
The requester should not receive the entire policy trace. Detailed explanations can reveal group names, thresholds, or the existence of restricted resources.
I split the response by audience.
A requester sees a safe reason and a next step:
This export requires a current approval.
Request a new approval and try again.
The service owner sees policy identifiers and dependency status:
policy=restricted-export@4.3
reason=APPROVAL_EXPIRED
decision=dec-9f31c2
An authorized investigator can retrieve the attribute provenance and evaluation trace using the decision identifier.
The explanation endpoint needs authorization of its own. Otherwise it becomes a convenient policy-discovery tool for an attacker.
Make the Next Step Real
A message is not actionable because it contains a link. The linked workflow must apply to the reason.
“Contact your administrator” is rarely useful. Which administrator? What information should the request include? Can the person receiving it change the outcome?
For common denial reasons, define an owner and a tested remediation. If there is no remediation because the action is prohibited, say that plainly.
REASON_CATALOG = {
"APPROVAL_EXPIRED": {
"message": "The approval for this export has expired.",
"next_step": "request_export_approval"
},
"DEVICE_STATUS_UNAVAILABLE": {
"message": "Device status could not be verified.",
"next_step": "retry"
},
"EXPORT_NOT_PERMITTED": {
"message": "This report cannot be exported.",
"next_step": None
}
}
Test What the User Sees
Authorization tests often stop after asserting the effect. Add assertions for the reason and the absence of sensitive detail.
def test_expired_approval_is_actionable():
response = export_report(approval=expired_approval())
assert response.status_code == 403
assert response.body["reason"]["code"] == "APPROVAL_EXPIRED"
assert response.body["reason"]["next_step"] == "request_export_approval"
assert "required_clearance" not in response.body
Track whether users succeed after following the suggested next step. If the same person repeats the denied action five times, the explanation probably did not help.
A Denial Can Still Be a Good Experience
Security controls will block legitimate people sometimes. That does not mean the control is wrong. It means the path out of a safe denial deserves design work.
Return a stable reason code. Give the user one safe explanation. Preserve the detailed evidence somewhere protected.
403 Forbidden can remain the HTTP status. It should not be the entire conversation.
What is the most useful, or most frustrating, authorization error you have encountered?
Top comments (1)
The audience split is the part I would keep, and there is now a fourth reader that changes some of the advice: an agent calling the endpoint on the requester's behalf.
It sits awkwardly between your three. It carries the requester's privilege, so it gets the safe reason, but it does not read a message and take a breath. It branches on whatever it is handed and acts in milliseconds. A few of the recommendations move under that.
next_stepis a machine contract rather than a label.DEVICE_STATUS_UNAVAILABLEwithnext_step: "retry"is good guidance for a person and a literal instruction to a client that will follow it immediately and keep following it. The posture service that just timed out is then taking retries from every denied caller at once, which is the failure you were failing closed to avoid. The catalog needs retry semantics sitting next to the reason: whether it is retryable at all, not before when, and how many times. That turns retry from advice into something a client can enforce and a server can observe.Noneis the wrong shape for terminal.EXPORT_NOT_PERMITTEDwithnext_step: Nonereads to a machine as a field that is missing rather than a door that is closed, and missing fields get retried. A terminal marker somebody has to read is safer than an absence they can skip. It also gives you one more thing to assert in the test you already wrote.Your warning about prose is understated rather than wrong. Someone will build a workflow around the exact punctuation, and that someone is now a model treating the message as the most informative field in the payload, because it is. So "the message can change without breaking clients" holds only if something in the contract says the message is not for machines. That costs one field and saves an argument later.
The last section is the one I would extend. The same person repeating a denied action five times is a good signal, and the threshold has to differ by principal type, because an agent does five in four seconds and that is not a comprehension problem, it is a loop. The useful part is that one counter measures explanation quality for humans and runaway retry for agents off the same data, with the response differing rather than the measurement. Echoing
decision_idon the retry would let you join a whole chain back to the denial that started it.On your question: the one I keep meeting is a 403 that is really an identity or audience problem upstream, presented as a permission decision. Everyone goes to read the policy, the policy is correct, and most of a day is gone before somebody looks at the token. Which is your unknown is not the same as no argument one layer further out. The system had not established that the caller lacked permission. It had established that it could not establish anything.