Skip to main content
Security Fundamentals · lesson 5 of 8
Security Fundamentals intermediate Lesson 5 of 8

Fail Closed: When an Exception Grants Access

Watch an authorization check grant access when its policy lookup raises (OWASP A10:2025), catch it with a failing pytest, and fix it to deny on error.

By SkillByExample · Published

security fail-closed secure-defaults owasp pytest authorization

An access check has three outcomes, not two: allow, deny, and couldn’t decide. The third one is where systems get breached. The policy service times out, a file is missing, a function returns None. If the code treats “couldn’t decide” as “allow”, it fails open. OWASP added this in 2025 as A10, Mishandling of Exceptional Conditions. This lesson shows the bug, catches it with a test, and fixes it to fail closed. Run with Python 3.12.15 and pytest 9.1.1 (pip install pytest in python:3.12-slim).

An authorization check that fails open

The policy lives in a separate service. To avoid locking everyone out during an outage, a developer made the check permissive on error.

# authz.py
class PolicyServiceDown(Exception):
    pass

POLICIES = {("alice", "read"), ("alice", "delete")}
SERVICE_UP = True

def lookup(user, action):
    if not SERVICE_UP:
        raise PolicyServiceDown("policy service timed out")
    return (user, action) in POLICIES

def is_allowed(user, action):
    try:
        return lookup(user, action)
    except Exception:
        return True     # "don't lock everyone out when the policy service is down"
# demo.py
import authz

for up in (True, False):
    authz.SERVICE_UP = up
    allowed = authz.is_allowed("mallory", "delete")
    print(f"policy service up={up}: mallory delete allowed={allowed}")
$ python demo.py
policy service up=True: mallory delete allowed=False
policy service up=False: mallory delete allowed=True

mallory has no permissions at all. While the service is up she’s correctly refused. When it goes down, the except branch returns True and she can delete. Her permissions didn’t change, the error did. An attacker who can make the lookup fail, by flooding the policy service, sending input that crashes the parser, or waiting for a deploy, gets every permission in the system. The comment explains the intent, and the intent is exactly the vulnerability.

A test that pins the failure mode

Happy-path tests won’t find this, because the bug only appears when a dependency breaks. So write a test that breaks it on purpose. monkeypatch simulates the outage without a real service.

# test_authz.py
import pytest
import authz

@pytest.fixture
def outage(monkeypatch):
    monkeypatch.setattr(authz, "SERVICE_UP", False)

def test_alice_can_delete():
    assert authz.is_allowed("alice", "delete") is True

def test_unknown_user_denied():
    assert authz.is_allowed("mallory", "delete") is False

def test_outage_denies(outage):
    allowed = authz.is_allowed("mallory", "delete")
    assert allowed is False, f"allowed={allowed} after the policy lookup raised"
$ pytest -q
..F                                                                      [100%]
=================================== FAILURES ===================================
______________________________ test_outage_denies ______________________________

outage = None

    def test_outage_denies(outage):
        allowed = authz.is_allowed("mallory", "delete")
>       assert allowed is False, f"allowed={allowed} after the policy lookup raised"
E       AssertionError: allowed=True after the policy lookup raised
E       assert True is False

test_authz.py:17: AssertionError
=========================== short test summary info ============================
FAILED test_authz.py::test_outage_denies - AssertionError: allowed=True after...
1 failed, 2 passed in 0.09s

The two ordinary tests pass, which is why this kind of bug survives code review. The outage test fails at line 17 with the exact symptom: allowed=True after the lookup raised. The assertion uses is False rather than not allowed on purpose. A check that returns None or an error string must not count as a pass.

The fix: deny on exception, grant only on True

Two changes. The handler denies and logs instead of granting, and the success path grants only on an explicit True.

# authz.py
import logging

log = logging.getLogger("authz")

class PolicyServiceDown(Exception):
    pass

POLICIES = {("alice", "read"), ("alice", "delete")}
SERVICE_UP = True

def lookup(user, action):
    if not SERVICE_UP:
        raise PolicyServiceDown("policy service timed out")
    return (user, action) in POLICIES

def is_allowed(user, action):
    try:
        return lookup(user, action) is True     # only an explicit True grants
    except Exception as exc:
        log.warning("DENY %s %s: policy lookup failed: %r", user, action, exc)
        return False
$ pytest -q
...                                                                      [100%]
3 passed in 0.09s
$ python demo.py
policy service up=True: mallory delete allowed=False
DENY mallory delete: policy lookup failed: PolicyServiceDown('policy service timed out')
policy service up=False: mallory delete allowed=False

The same test file now passes, and the demo refuses mallory in both states. Failing closed has a real cost: during the outage, alice is refused too. That’s why the DENY line matters. An outage that locks users out gets noticed and fixed, while one that quietly lets everyone in can run for months. If availability during outages really matters, cache the last known good policy with a short expiry. Never fall back to “allow”. Catching the broad Exception is deliberate here. Any failure, including a bug in lookup itself, should end in a denial.

The quiet version: None is falsy

No exception is needed to fail open. Here a helper returns None when it can’t read the ban list, and the caller’s truthiness check reads “unknown” as “not banned”.

# denylist.py
def is_banned(user):
    """True if banned, False if not, None if the ban list could not be read."""
    try:
        with open("/etc/app/banned.txt") as f:
            return user in f.read().split()
    except OSError:
        return None

def can_login_bad(user):
    if is_banned(user):            # None is falsy, so "unknown" reads as "not banned"
        return False
    return True

def can_login_good(user):
    return is_banned(user) is False   # only a definite "not banned" lets you in

print("ban list readable? no (file missing)")
print("bad :  mallory can log in:", can_login_bad("mallory"))
print("good:  mallory can log in:", can_login_good("mallory"))
$ python denylist.py
ban list readable? no (file missing)
bad :  mallory can log in: True
good:  mallory can log in: False

The helper did its job. It returned a distinct value for “couldn’t tell” and documented it. The caller threw that information away with if is_banned(user):. A denylist is an allow-by-default design to begin with, so any gap in it fails open. Where you can, invert it into an allowlist. Where you can’t, compare with is False so only a definite answer lets anyone through.

Secure defaults: a missing setting must not switch protection off

Configuration fails open the same way. Compare two ways of reading an AUTH_REQUIRED flag across the values an operator might really set.

# settings.py
def auth_required_bad(env):
    return env.get("AUTH_REQUIRED", "false") == "true"     # off unless spelled exactly

def auth_required_good(env):
    return env.get("AUTH_REQUIRED", "true") != "false"     # on unless spelled exactly

cases = [{}, {"AUTH_REQUIRED": "true"}, {"AUTH_REQUIRED": "True"},
         {"AUTH_REQUIRED": "ture"}, {"AUTH_REQUIRED": "false"}]
print(f"{'AUTH_REQUIRED':<16}{'bad':<7}good")
for env in cases:
    value = repr(env.get("AUTH_REQUIRED", "<unset>"))
    print(f"{value:<16}{auth_required_bad(env)!s:<7}{auth_required_good(env)}")
$ python settings.py
AUTH_REQUIRED   bad    good
'<unset>'       False  True
'true'          True   True
'True'          False  True
'ture'          False  True
'false'         False  False

The bad version turns authentication off for a missing variable, a capitalised True, and a typo. Only one exact spelling turns it on. The good version inverts that: protection stays on unless someone types exactly false. Both are one line, and only the default differs. Where the safe value goes decides which way every mistake falls. The second practice task goes one step further and refuses to start on a value it doesn’t recognise.

Practice

  1. Pin the quiet failure in a test. Add tests where lookup returns None, and where it returns the string "error: timeout", without raising. Run them against both versions of authz.py.
Solution
# test_none.py
import authz

def test_none_from_backend_is_denied(monkeypatch):
    monkeypatch.setattr(authz, "lookup", lambda user, action: None)
    assert authz.is_allowed("alice", "delete") is False

def test_truthy_string_is_not_a_grant(monkeypatch):
    monkeypatch.setattr(authz, "lookup", lambda user, action: "error: timeout")
    assert authz.is_allowed("alice", "delete") is False

Against the fixed authz.py:

$ pytest -q test_none.py
..                                                                       [100%]
2 passed in 0.08s

Against the original, fail-open authz.py, both fail:

$ pytest -q test_none.py
FF                                                                       [100%]
=================================== FAILURES ===================================
_______________________ test_none_from_backend_is_denied _______________________

monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f4c22c31370>

    def test_none_from_backend_is_denied(monkeypatch):
        monkeypatch.setattr(authz, "lookup", lambda user, action: None)
>       assert authz.is_allowed("alice", "delete") is False
E       AssertionError: assert None is False
E        +  where None = <function is_allowed at 0x7f4c22ba1620>('alice', 'delete')
E        +    where <function is_allowed at 0x7f4c22ba1620> = authz.is_allowed

test_none.py:6: AssertionError
______________________ test_truthy_string_is_not_a_grant _______________________

monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f4c22c32960>

    def test_truthy_string_is_not_a_grant(monkeypatch):
        monkeypatch.setattr(authz, "lookup", lambda user, action: "error: timeout")
>       assert authz.is_allowed("alice", "delete") is False
E       AssertionError: assert 'error: timeout' is False
E        +  where 'error: timeout' = <function is_allowed at 0x7f4c22ba1620>('alice', 'delete')
E        +    where <function is_allowed at 0x7f4c22ba1620> = authz.is_allowed

test_none.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_none.py::test_none_from_backend_is_denied - AssertionError: asser...
FAILED test_none.py::test_truthy_string_is_not_a_grant - AssertionError: asse...
2 failed in 0.08s

The original returns whatever lookup returned, so an error string is truthy and would pass any if is_allowed(...): check. The fixed version’s is True turns both into denials.

  1. Refuse to start on a bad flag. Write parse_flag(env, name, default=True) that accepts true/1/yes and false/0/no in any case and with surrounding spaces, and raises on anything else.
Solution
# settings_strict.py
def parse_flag(env, name, default=True):
    raw = env.get(name)
    if raw is None:
        return default
    value = raw.strip().lower()
    if value in ("true", "1", "yes"):
        return True
    if value in ("false", "0", "no"):
        return False
    raise ValueError(f"{name}={raw!r} is not a boolean; refusing to start")

for raw in [None, " False ", "ture"]:
    env = {} if raw is None else {"AUTH_REQUIRED": raw}
    try:
        print(repr(raw), "->", parse_flag(env, "AUTH_REQUIRED"))
    except ValueError as e:
        print(repr(raw), "->", e)
$ python settings_strict.py
None -> True
' False ' -> False
'ture' -> AUTH_REQUIRED='ture' is not a boolean; refusing to start

A crash at startup is failing closed for configuration. The typo is found in the deploy log within seconds, instead of in an incident report months later.

  1. Load a policy file safely. Read [{"user": ..., "action": ...}] from a JSON file. If the file is missing or malformed, log an error and deny everything. Test it with a missing file and a truncated one.
Solution
# policy_file.py
import json, logging

logging.basicConfig(format="%(levelname)s %(message)s")

def load_policy(path):
    try:
        with open(path) as f:
            return {(r["user"], r["action"]) for r in json.load(f)}
    except (OSError, ValueError, KeyError, TypeError) as exc:
        logging.error("policy %s unusable (%r); denying everything", path, exc)
        return set()

with open("bad.json", "w") as f:
    f.write('[{"user": "alice", "action": "read"},')    # truncated write

for path in ["missing.json", "bad.json"]:
    policy = load_policy(path)
    print(path, "-> alice read allowed:", ("alice", "read") in policy)
$ python policy_file.py
ERROR policy missing.json unusable (FileNotFoundError(2, 'No such file or directory')); denying everything
missing.json -> alice read allowed: False
ERROR policy bad.json unusable (JSONDecodeError('Expecting value: line 1 column 38 (char 37)')); denying everything
bad.json -> alice read allowed: False

An empty set is the fail-closed policy: nothing is granted. KeyError and TypeError are caught too, because a file that’s valid JSON but has the wrong shape is just as unusable. JSONDecodeError is a subclass of ValueError, so it’s covered.

Frequently Asked Questions

What does fail closed mean in security?
A system fails closed when an error, timeout or missing input leaves it in the safe state, which for an access check means denying the request. Failing open means the error leaves access granted. A login page that lets everyone in when the user database is unreachable fails open. One that shows an error and lets no one in fails closed.
What is OWASP A10:2025 Mishandling of Exceptional Conditions?
It is a category added in the OWASP Top 10:2025. It covers software that handles errors, timeouts and unexpected states badly, for example by failing open, leaking details in error messages, or leaving a transaction half applied. The usual fixes are to catch specific exceptions, deny by default when a security decision cannot be made, and log the failure.
Why is except Exception: return True dangerous in an authorization check?
It turns every failure into a grant. A database timeout, a bug in the policy code, or a dependency an attacker can disrupt all end up allowing the request. An attacker who can make the lookup fail, for example by overloading the policy service, gets access without any valid permission. Return False in the handler and log the error instead.
Should a security check compare with is True or rely on truthiness?
Compare explicitly. In Python, None, an empty string and 0 are falsy while any non-empty string is truthy, so a function that returns None on error or an error message string can be misread as a decision. Only grant access when the check returns exactly True, and treat every other value, including None, as a denial.
What is a secure default for a configuration flag?
A secure default is the value that keeps protection on when the setting is missing or mistyped. For example, authentication should be required unless the configuration explicitly and correctly turns it off. Better still, reject unrecognised values at startup, so a typo such as ture stops the service instead of silently changing its security.