Classic painting used as the article cover
← Back to blog

Failure behavior

Fail-Closed Agent Authorization: When the Decision Can't Be Made

Timeouts, unreachable checks, unknown scopes and missing annotations all produce the same question. OWASP's Agent Control Standard ships a fail-open default, and the reasoning is worth reading closely.

•Oct 12, 2026•15 min
SecurityGovernanceIdentity

TL;DR

Every authorization system has a third outcome besides allow and deny: no answer. Most agent stacks have never written down what they do with it, and the one open standard that has written it down chose to proceed.

OWASP's Agent Control Standard is explicit. A step suffers a decision failure "when no usable decision arrives within the negotiated timeout", and all such cases "resolve the same way: the Observed Agent applies the deployment's failure posture, declared as on_decision_failure in the handshake and defaulting to proceed (fail-open) so that a slow, erroring, or unreachable Guardian does not halt production."

The usual mistake is not choosing fail-open. It is never choosing, and discovering the default during an outage. ACS at least states the trade in its own text: "an adversary who can disrupt the channel converts control into audit."

We are not arguing every deployment should deny on every ambiguity. A blanket deny on an unreachable check turns a network blip into an outage, which is why ACS defaults the way it does.

What we would argue is narrower. Write the posture down per decision class, make the timeout bounded and short, and make the denial recoverable where the protocol gives you a way to say what was missing.

Overview

Take a benefits administration agent at a hospital group. It handles employee life-event changes: a new dependent, a plan switch, an address correction that changes tax withholding. Every action runs through an authorization check that asks whether this agent, acting for this HR coordinator, may modify this employee's record.

One Tuesday the service behind that check gets slow. Not down, slow, in the way that a connection pool exhaustion makes things slow: most calls return in 40 milliseconds and some take nine seconds. The agent is mid-run on a batch of 300 changes. What does it do with the ones it cannot get an answer for?

There are exactly two answers and both are defensible. What is not defensible is not knowing which one your stack picked. Agent authorization vs. authentication covers the 401 and 403 split, and permission models for autonomous systems covers how much initiative an agent gets once it is allowed in. This post is about the third branch, the one nobody draws: the decision that never arrives, or arrives in a form your code does not understand.

The thing to write down: for each class of decision failure, the posture and who chose it. "We fail closed" is a claim about code, and in our reading of the available specifications it is usually a claim about code that was never written.

Four Ways to End Up Without an Answer

"Fail closed" gets used as though decision failures were one thing. They are at least four, and they do not want the same treatment.

The check times out or is unreachable. The authorization service is slow, the connection is refused, the response is malformed. This is the availability case, and it is the one where fail-open has a real argument: a transient network condition should probably not stop payroll.

A scope or grant is unrecognised. The identity provider added a group, a vendor renamed a scope, your mapping table has an entry the other side has never heard of. Here the system is not broken and nothing is slow. It simply cannot tell whether the action is in the intersection, which is the subject of agent scope and user permission.

Metadata the decision depended on is absent. A tool arrives with no annotation saying whether it writes. A resource carries no classification. The decision procedure has a hole in its inputs rather than a missing output.

The decision arrives but cannot be applied. The authorizer says "allow this, with these arguments rewritten" and the agent framework has no way to rewrite arguments mid-flight.

Those four want different defaults. Collapsing them into one global setting is how a system ends up permissive in exactly the case it least wanted to be, because the setting was chosen with the timeout case in mind.

allow

deny

needs approval

no usable answer

proceed

deny

Agent proposes an action

decision within
bounded timeout?

Execute

Block and record

Pause for a human

declared posture
for this class

Execute, audit the bypass

Block, say what was missing

Audit record names the posture

Figure 1 — The branch most architecture diagrams omit. The posture lookup is per failure class, not global, and the audit record has to name which posture applied or the bypass is invisible afterwards.

What OWASP's Agent Control Standard Actually Defaults To

The Agent Control Standard, an open project of the OWASP GenAI Security Project, is the sharpest public artifact on this that we have found, because instead of recommending a posture it specifies one and then argues with itself about the choice in the normative text. It models a Guardian that rules on each step an Observed Agent proposes, over a negotiated handshake.

Its disposition vocabulary has five values: ALLOW, DENY, MODIFY ("proceed with changes"), ASK ("pause and request approval") and DEFER ("verdict not yet reachable"). DEFER is itself a decision: the Guardian saying it cannot decide yet, with a reason drawn from a fixed list that includes insufficient_context and conflicting_policies. Silence is a different thing entirely.

Silence is handled in section 6.4. "A step suffers a decision failure when no usable decision arrives within the negotiated timeout: the Guardian stays silent, the transport fails (connection refused, TLS failure, malformed response), or the Guardian returns an error instead of a decision." All three "resolve the same way: the Observed Agent applies the deployment's failure posture, declared as on_decision_failure in the handshake (§4) and defaulting to proceed (fail-open) so that a slow, erroring, or unreachable Guardian does not halt production. A deployment MAY set on_decision_failure: deny (fail-closed)."

The reference implementation makes the default concrete as an environment variable. Its configuration table lists ACS_ON_DECISION_FAILURE with a default of proceed, described as "the posture the ServerHello declares. deny fails closed. Any other value stops the Guardian at startup."

Two further details are worth having. First, the standard will not let the bypass be quiet: "Every step that proceeds without a decision MUST be recorded as an audit event, so the bypass is visible rather than silent." Second, it states the cost of its own default in one sentence: "Fail-open trades enforcement for availability under disruption: an adversary who can disrupt the channel converts control into audit."

Read that sentence as a threat model, not a caveat. If disruption is cheap for an attacker and your posture is proceed, your authorization layer is a logging layer with extra latency.

The asymmetry inside the standard is the most instructive part. An expired DEFER defaults the other way: "timeout_decision defaulting to deny is deliberately the opposite of on_decision_failure defaulting to proceed (§6.4). An expired DEFER is a Guardian that flagged a concern and failed to resolve it, not one that never answered: a degraded Guardian that can still emit DEFER fails closed where a silent one fails open."

So the standard already does what we argued for above: it splits decision failure into classes and picks a different posture per class. A Guardian that said "I have a concern" and then timed out blocks. A Guardian that said nothing at all does not. There is also a startup posture, because the handshake cannot govern itself: when handshake/hello fails, the agent applies a posture configured out of band, where "proceed starts the session unguarded, refuse does not start it", and "the default is proceed". A deployment that sets on_decision_failure: deny and leaves the startup posture alone has a fail-closed session and a fail-open way of never having a session.

Approval as a disposition, including what it costs in human attention, is covered separately in human-in-the-loop approval design.

Unknown Scopes and Missing Hints

The second and third failure classes are quieter and, we think, more dangerous, because they produce no error anywhere.

For scopes, the rule follows from the intersection: an unmatched grant contributes nothing. If the acting user's token carries reports:export:v2 and the agent's configuration knows only reports:export, the correct answer is that the overlap for that action is empty. The tempting alternative is prefix matching, which quietly turns every future scope a vendor invents into a grant you already made.

For metadata, MCP's 2026-07-28 specification carries the sharper warning. A tool's annotations field is listed as "Optional properties describing tool behavior", and the spec attaches a normative caution: "For trust & safety and security, clients MUST consider tool annotations to be untrusted unless they come from trusted servers."

That sentence does two things at once, and both matter for failure behavior. It makes a missing annotation and a present annotation equally uninformative when the server is not trusted, which means a decision procedure keyed on annotations has no inputs at all in the untrusted case. And it puts the burden on the client, not the server, so "the tool said it was read-only" is not an answer anyone will accept afterwards. The annotation defaults and what they are good for are covered in permission models for autonomous systems; the point here is narrower. An annotation is a hint for building an interface and prioritising review. It is not an input to an authorization decision, so a decision procedure that stalls when one is absent was reading the wrong field in the first place.

OWASP's general design guidance says the same thing in one line, outside any agent context: "Design systems to fail in a secure state, rather than exposing vulnerabilities when they malfunction."

The Recoverable Denial

Fail-closed has a reputation problem because people imagine the deny as a dead end. The HTTP layer has carried a better pattern for fourteen years, and MCP has now specified how agents should use it.

RFC 6750 defines insufficient_scope: "The request requires higher privileges than provided by the access token. The resource server SHOULD respond with the HTTP 403 (Forbidden) status code and MAY include the scope attribute with the scope necessary to access the protected resource." A denial that names what was missing is a different object from a denial that does not. The first one is a protocol message. The second is a support ticket.

MCP's authorization spec makes that concrete for agents. On an insufficient-scope error at runtime the server SHOULD return HTTP 403 Forbidden with a WWW-Authenticate header carrying error="insufficient_scope", the scope parameter "specifying the minimum scopes needed for the operation", and the resource metadata URI. Clients "SHOULD respond to these errors by requesting a new access token with an increased set of scopes via a step-up authorization flow", computing "the union of the client's previously requested scope set and the scopes from the current challenge" so previously granted permissions survive the re-authorization.

The spec is also clear about who may take that route. "Clients acting on behalf of a user SHOULD attempt the step-up authorization flow. Clients acting on their own behalf (client_credentials clients) MAY attempt the step-up authorization flow or abort the request immediately." An autonomous agent with no user attached has nobody to ask, which is a good reason to prefer the on-behalf-of shape described in what on-behalf-of authorization actually proves.

RFC 9470 extends the same mechanism one layer deeper, for the case where the problem is not the scope but the authentication behind it. Its error value is insufficient_user_authentication, defined as: "The authentication event associated with the access token presented with the request does not meet the authentication requirements of the protected resource." With acr_values and max_age in the challenge, a resource server can say "this one needs a stronger or fresher sign-in" rather than just no.

ResourceAuthorizerAgentResourceAuthorizerAgentslow: connection pool exhaustedmay this user do action X?no answer inside 250ms budgetposture for timeouts = denyrecord: denied, reason=decision_timeoutretry once inside remaining budgetALLOWperform action X403 insufficient_scope, scope="records:write"step-up: union of old and challenged scopes

Figure 2 — Two different denials in one run. The first has no information in it and is bounded by a budget; the second names exactly what was missing and is therefore recoverable without a human.

A Fail-Closed Engine, Concretely

Abstract postures are easy to agree with, so here is one in a shipped system. mnemiq is our open-source text-to-SQL engine, and its access behaviour is fail-closed by construction rather than by configuration.

Its own description of the ordering: a model proposes a query, and a deterministic layer then "rules on it before anything touches the database. The query has to be read-only, may only reference objects the caller is allowed to see, has to compile in the source's own SQL dialect, and has to survive an EXPLAIN. Fail any of those and you get a refusal with a stated reason rather than a plausible number."

The part that matters for failure behavior is what happens with no inputs. From the README: "Access is fail-closed. A role is required", and "Without a role the engine grants nothing and defers, which is the correct behaviour and the first thing people mistake for a bug." That last clause is the honest cost of the posture. The first-run experience of a fail-closed system is an error message, and the engineering temptation is always to make the empty case permissive so the demo works.

One design consequence is worth stealing even if you never run mnemiq. "Permissions apply before schema retrieval, so the model is never shown a table the caller may not see", which means naming a forbidden table "is useless rather than refused". A system that filters after generation has to refuse; a system that filters before retrieval has nothing to refuse, and the model cannot hallucinate a permission it was never shown. The broader design is covered in governed data agents.

What Fail-Closed Costs, and How to Pay It

The objection to fail-closed is availability, and it is a real objection, not a rhetorical one. ACS picked its default for exactly that reason. Three things make the bill affordable.

Bound the timeout and make it short. A 250 millisecond budget with one retry inside the remaining window turns "the authorizer is slow" into a few extra denials rather than a stalled run. ACS puts this in the normative text: the negotiated timeout "bounds every failure mode", and "an unambiguous failure (a refused connection) MAY resolve immediately rather than waiting out the clock". A fail-closed posture with an unbounded timeout is the worst of both, because it hangs instead of deciding.

Cache the allow, never the deny-by-failure. A decision that was genuinely made can be reused for a short window keyed on agent, user, action and resource. A non-answer must never be cached, because caching it converts one network blip into a sustained outage for that tuple.

Refuse in a way the caller can act on. This is where the taxonomy's third term earns its place. aact:failure-behavior/refuse-and-explain is defined as behaviour where "the agent declines, tells the requester what it could not do and why, and does not substitute a partial or guessed result." The two postures it sits beside are aact:failure-behavior/fail-open, where "if policy is missing, unreachable or ambiguous, the action proceeds", and aact:failure-behavior/fail-closed, where the action "is denied and the denial is recorded". This is the failure behavior dimension of the Agent Access Control Taxonomy, whose stated question for that dimension is exactly the one this post started with: what happens when the decision cannot be made.

Note the second half of the fail-closed definition. The denial is recorded. A deny that leaves no trace is operationally almost as bad as a silent allow, because the people who could fix the mapping never learn it is wrong.

Frequently Asked Questions

What does fail-closed mean for agent authorization? That when the authorization decision cannot be made, the action is denied and the denial is recorded. The decision failing is treated as a denial rather than as a reason to skip the check.

What is the difference between fail-open and fail-closed? Fail-open proceeds when the decision is missing, unreachable or ambiguous. Fail-closed denies. Fail-open preserves availability and converts your authorization layer into an audit layer whenever the channel is disrupted, which is the trade OWASP's Agent Control Standard names in its own text.

Does OWASP's Agent Control Standard fail open or closed by default? Open. The standard defaults on_decision_failure to proceed, so a slow, erroring or unreachable Guardian does not halt production, and a deployment MAY set on_decision_failure: deny. The reference implementation exposes this as ACS_ON_DECISION_FAILURE, also defaulting to proceed. The startup posture, for a handshake that never completes, defaults to proceed as well, so both need changing to get a fail-closed deployment.

What should an agent do when a tool annotation is missing? Treat the absence as uninformative, not as permission. MCP's specification requires clients to consider tool annotations untrusted unless they come from trusted servers, so a present annotation from an untrusted source is worth no more than a missing one. Annotations are for interface design and review triage, not authorization inputs.

What does a 403 insufficient_scope mean for an agent? That the token is valid but too narrow for this operation. Under RFC 6750 the response may carry a scope attribute naming what is needed, and MCP's authorization spec tells clients to step up by requesting a new token for the union of their previous scopes and the challenged ones, with retry limits so a loop cannot form.

How do I stop fail-closed from causing outages? Bound the decision timeout tightly, retry once inside the remaining budget, cache decisions that were actually made, and never cache a non-answer. Most of the pain attributed to fail-closed comes from an unbounded wait rather than from the denial.

Is denying on an unrecognised scope too strict? It is the only safe reading of an intersection. A grant neither side recognises cannot be shown to be inside both sets. The alternative, prefix or wildcard matching, silently grants every future scope a vendor invents.

Conclusion

The reason this dimension deserves its own page is that it is the one most likely to be undocumented and the one most likely to decide what actually happens in an incident. Identity, delegation and scope models get designed. Failure behavior gets inherited from a library default, and in at least one published standard that default is to proceed.

So the work is small and specific. Enumerate the ways your stack can fail to get an answer, which is more than one. Assign a posture to each, and write down who chose it. Bound the wait. Make the recoverable denials carry their reason, because the protocols already have a field for it and a 403 that names the missing scope costs a round trip instead of a ticket. Then audit the bypasses, which ACS is right to make mandatory: a fail-open path nobody can count is indistinguishable from no authorization at all.

Unplug it and watch. Point your agent at a batch of real actions, make the authorizer unreachable, and see what the run does. Whatever happens next is your actual posture, and it is the only one your auditors will be able to observe.

Sources

All URLs read 2026-10-12.