Your permission check calls out to a service, a cache, or a database to decide whether a user may do something. That call can fail, timeout, throw, or return a value the code was not expecting. What happens next is the whole security posture of that check, and in a surprising amount of code, what happens next is that the user is let in.
This is not usually a deliberate decision. It is what a broad exception handler does by default, or what a boolean check does when it is written to catch the failure case loosely rather than precisely. Nobody chose to fail open. The code simply did, because failing open is what happens when an error path is not specifically considered.
This post is about the difference between an authorization check that fails safely and one that merely fails, and why the two look identical until something actually breaks.
The shape of the bug, in its most common form
try {
const allowed = await checkPermission(user, resource);
if (!allowed) return res.status(403).end();
} catch (e) {
// log and continue
console.error(e);
}
proceedWithAction();
The intent, almost always, is to be resilient: a permission service having a bad moment should not take down every feature that depends on it. The actual effect is that any failure in the permission check, a timeout, a network blip, a bug in the check itself, an unexpected response shape, results in the action proceeding as though permission had been explicitly granted. The catch block was written to handle an operational problem and it silently became an authorization bypass.
Why this specific bug is so persistent
It requires an error to trigger, so it never appears in normal testing. A test suite exercising the happy path, and even most negative paths, calls the permission check successfully and gets a real answer. The fail-open behaviour only activates when the check itself breaks, which is exactly the condition testing rarely simulates deliberately.
It looks like defensive programming, so it survives code review. A broad catch block around an external call reads as good practice to a reviewer scanning quickly, because broad catch blocks around external calls generally are good practice, for the failure modes that are not security decisions. The reviewer's instinct to approve resilient-looking code works against them here specifically because the code being reviewed is not merely operational, it is a security control.
The failure that triggers it is often the exact failure that correlates with an attack. A permission service under unusual load, behaving erratically, timing out, or returning malformed responses, is a plausible symptom of someone actively probing your system, not merely a random infrastructure blip. The condition most likely to trigger fail-open behaviour is disproportionately likely to be exactly the moment you most need the check to hold.
Where this appears beyond the obvious try/catch
A permission check that defaults to true on an unrecognised response shape, because the code path only explicitly handles the responses it expected and treats anything else as implicitly fine, rather than treating anything unexpected as a reason to deny.
A feature flag or configuration lookup that fails silently and returns the more permissive of two options, because the code was written assuming the lookup would generally succeed and the fallback value was chosen for convenience during development rather than deliberately as the safe default.
A distributed cache for authorization decisions that, on a cache miss or connection failure, is coded to allow rather than to re-check against the source of truth or deny, because the cache was added for performance and its failure mode was never specifically considered as distinct from its normal operation.
Middleware ordering where an authentication or authorization check that throws is caught by a generic error handler positioned after it in the chain, which converts what should be a 401 or 403 into whatever the generic handler's default response happens to be, sometimes a plain success if the handler was not written with this specific case in mind.
What fail-closed actually looks like
The check has to be structured so that every outcome other than an explicit, well-formed "allowed" results in denial, with no default path that proceeds.
let allowed;
try {
allowed = await checkPermission(user, resource);
} catch (e) {
logSecurityEvent('permission_check_failed', { user, resource, error: e });
return res.status(503).json({ error: 'Unable to verify permission, try again' });
}
if (allowed !== true) {
return res.status(403).end();
}
proceedWithAction();
Three things changed. The catch block denies rather than continuing. The success path checks for an explicit, exact true rather than treating "did not explicitly say no" as sufficient. And the failure is logged as a security-relevant event specifically, not merely as an operational error, because a spike in permission-check failures is itself a signal worth someone seeing.
The trade this creates, honestly
Fail-closed means a genuine outage in your permission service becomes an outage in every feature that depends on it, rather than a degraded but functioning experience. For most systems, this is the correct trade: a temporary inability to perform an action is recoverable and visible, while an unintended authorization bypass is neither, and may not be discovered until long after it mattered.
Where availability of the feature genuinely matters more than the specific permission check, that is a decision to make explicitly, with a scoped, deliberate fallback, rather than an accident of how a catch block happened to be written. A documented decision that a specific low-consequence feature degrades to a narrower, explicitly safe default under failure is defensible. An undocumented accident that a payment or data-access check defaults to allow under failure is not, regardless of how reasonable the code looked at the time it was written.
Check yours
Search your codebase for permission and authorization checks, and read the failure path of each one specifically, not the success path, which is what most review attention goes to by default:
grep -rn "checkPermission\|hasAccess\|authorize\|can(" --include=*.js --include=*.ts src/ \
| xargs -I{} echo {}
For each result, find the surrounding error handling and ask one question: if this call throws, times out, or returns something unexpected, does the action proceed or does it stop. If the answer requires tracing through several layers of exception handling to determine, that ambiguity is itself the finding, independent of what the actual answer turns out to be.
The concession
Fail-closed everywhere, applied without judgement, produces a system that treats every transient infrastructure hiccup as a security event and every dependency failure as a reason to block users from doing anything, which has its own real cost in availability and in how much attention a team can give to alerts before they stop reading them.
The distinction that resolves this is the same one that runs through this entire category: consequence should determine the default. A check gating access to another customer's data or an irreversible action should fail closed without exception. A check gating a cosmetic feature or a non-sensitive convenience can reasonably have an explicit, narrow, safe fallback rather than a hard failure, provided that fallback was chosen deliberately rather than inherited from a broad catch block nobody examined closely.
The implication
An authorization check that has never been tested against its own failure is not a known-good control, it is an untested one, and the two are indistinguishable until the day the dependency it relies on actually breaks.
Trace the failure path of your most consequential permission check today, deliberately, by simulating the failure rather than reading the code and assuming. If the action proceeds when the check itself fails, that is not resilience. It is the control quietly not existing under exactly the conditions where you needed it most.