Fail Open Access Control and the Empty Allowlist

Fail Open Access Control and the Empty Allowlist

A fail open access control bug happens when a permission check exists but defaults to allow when something is missing, empty, or unexpected. The code looks careful. There is a guard, an allowlist, a role lookup. But when the input the guard depends on is empty or absent, the guard quietly steps aside and lets everything through. This is the opposite of what you want, and it hides in code that reads as correct.

What fail open access control actually means

Every access check is a choice between two failure modes. When the check cannot make a clean decision, it either fails closed and denies, or it fails open and allows. Fail closed is safe by default. Fail open is convenient by default. The trouble is that a lot of code fails open by accident, because an empty value or a missing key happens to route past the block that was supposed to stop it.

Here is the shape you see most. An app builds a list of allowed items, then checks membership against it.

def can_view(item, allowed):
    if allowed and item not in allowed:
        deny()
    return serve(item)

Read the guard closely. It only denies when allowed is truthy and the item is not in it. So what happens when allowed is an empty list, or None? The first half of the condition is false, the whole condition is false, and deny() never runs. An empty allowlist stops restricting anything. It permits every item instead of none. The author almost certainly meant “empty means block everything,” but the code says “empty means skip the check.”

The empty allowlist, in plain terms

Imagine an invented app, Acme Reports, where a manager configures which report types a team can open. The config feeds an allowlist. On a normal day it holds three entries, and the guard above works fine. Then a migration runs, a lookup returns nothing, or a new team is created with no reports assigned yet. Now allowed is []. A user on that team requests a payroll report.

GET /reports/payroll
allowed = []   # resolved from empty config

The guard sees an empty list, treats it as “no restriction,” and serves the payroll report to someone who should see nothing. The bug did not come from a typo. It came from treating “the set of allowed things is empty” as the same as “there is no rule here.” Those two ideas are opposites, and the code collapsed them into one.

An empty allowlist should mean deny everything, but code that reads it as “no rule set” makes it mean allow everything.

Two more shapes worth knowing

The empty allowlist is the headline, but fail open wears other clothes. A role check that returns true on an unexpected value is one.

def is_allowed(role):
    if role == "guest":
        return False
    return True   # anything not "guest" passes

This looks like it blocks guests and allows the rest. But think about what happens when role is None because the profile never loaded, or a new role like "contractor" that nobody wrote a rule for. It is not "guest", so it returns true. Any value the author did not think about becomes allow. A safer version names the roles that pass and denies everything else.

The third shape is a try block that swallows the failure and keeps going.

try:
    check_permission(user, resource)
except Exception:
    pass   # swallowed
serve(resource)

If check_permission raises, maybe the auth service timed out, maybe a key was missing, the except eats the error and the code walks straight to serve. An error inside the gate became an open gate. The user gets the resource precisely because the check broke. The fix is to let the failure deny: on any error, stop and return a 403.

Why a scanner walks right past fail open access control

Here is the part that makes these bugs stubborn. There is no bad pattern to match. The syntax is clean. There is no eval, no raw SQL string, no dangerous function name, no obvious injection sink. A signature based scanner looks for known bad shapes in the text, and if allowed and item not in allowed is not a known bad shape. It is ordinary, readable code. A grep for risky calls finds nothing. A linter sees a normal conditional. The guard is present, so a tool that checks “is there an authorization call here” answers yes and moves on.

The bug is not in any single line. It lives in what allowed resolves to at run time. To catch it you have to follow the value, not the words. You have to ask: can allowed ever be empty or None when it reaches this guard, and if it can, what does the guard do then? That is a dataflow question over the source, tracing where the list comes from, whether any path produces an empty one, and where that empty value lands.

An analyzer that follows values instead of text can flag this. It can see that an explicitly empty allowlist reaches a membership guard that only denies on a non empty list, and that this selects the full set rather than the empty set. That is a candidate, not a verdict. It still needs a human to confirm the empty state is reachable and that “allow everything” is the real effect. But it points you at the exact spot a text scanner slid past.

A short checklist of where to look

  • Membership guards. Any if allowlist and x not in allowlist. Ask what an empty or None list does. If empty skips the deny, it fails open.
  • Role and flag checks that end in a default true. A final return True or an else: allow means every value you forgot about is permitted.
  • try blocks around auth. A check wrapped in try with an except that passes, logs, or continues. An error should deny, not proceed.
  • Config or lookups that can return empty. Allowlists built from a database, a feature flag, or an external service. Any of them can hand you an empty set on a bad day.
  • Optional values used as gates. A permission read from a nullable field, where missing is treated as “no restriction” instead of “no access.”

The habit that catches all five is the same one that finds most access control bugs: ask what the code trusts and what happens when that thing is empty. If you want the wider picture, the access control category collects related writing. Fail open is close kin to the missing check in what an access control vulnerability is, and it often sits right beside broken function level authorization where a whole function forgets its gate.

Fix it by failing closed

The repair is a design decision, not a patch. Decide that every gate denies when it cannot make a clean allow. Make empty mean deny. Name the values that pass and reject the rest. Let auth errors stop the request. Going back to Acme Reports, the guard should read: if I do not have a positive, explicit reason to allow this exact item for this exact user, I deny. Then an empty allowlist blocks everything, which is what the manager meant all along.

This is exactly the kind of bug an autonomous researcher that follows values rather than patterns is built to surface, because the danger is not in a line of code but in what that line resolves to when the data goes empty. Learn more on the about page.

Frequently asked questions

What is fail open access control?

It is when a permission check exists but defaults to allow instead of deny when its input is missing, empty, or unexpected. The guard is present in the code, so it looks correct, but an empty allowlist or a null role routes past the block and lets the request through. Fail closed is the safe opposite: when the check cannot make a clean allow, it denies.

Why does an empty allowlist permit everything?

A common guard reads if allowed and item not in allowed: deny. When allowed is an empty list or None, the first half of the condition is false, so the whole condition is false and the deny never runs. The code treats an empty allowlist as “no rule set” rather than “block everything,” which are opposite meanings. The result is that every item is served.

Why do scanners miss fail open access control?

Because there is no bad pattern to match. The syntax is clean, the authorization call is present, and there is no injection sink or dangerous function name. A signature scanner checks whether a guard exists and moves on. The bug lives in what the allowlist resolves to at run time, so you have to follow the value through the code, not search the text.

How do I fix a fail open check?

Fail closed by design. Make empty mean deny, not skip. Name the roles or items that are allowed and reject everything else instead of ending in a default true. Let auth errors stop the request with a 403 rather than swallowing the exception and continuing. The rule is simple: if there is no explicit reason to allow, deny.


Put an autonomous researcher on your own systems

UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.