AWS IAM Policy Evaluation: Why an Explicit Deny Beats Any Allow
8 min readBytePatterns
How AWS decides whether a request is allowed: default deny, explicit allow, explicit deny wins, and the guardrail policies that can only take access away.
"A user has an Allow for s3:* and a Deny for s3:DeleteObject. Can they delete?" Most engineers answer correctly — no — but far fewer can say why, or what happens when there is no policy at all, or where an organisation-wide guardrail fits in. IAM's evaluation logic is short enough to learn exactly. Every rule below is taken from the AWS documentation listed at the end, as published as of September 2026.
The problem it solves
Under the AWS shared responsibility model, AWS secures the cloud itself: the facilities, hardware, network and virtualization layer. What you put in it is yours to secure — your data, guest operating system patches, firewall rules, encryption, and who may call which API. IAM is how you answer that last question: AWS evaluates the applicable policies for each request before deciding it.
The difficulty is that permissions come from many places at once: policies attached to a user or role, policies attached to a resource such as a bucket, organisation-wide policies, permissions boundaries, session policies. You need one rule for combining them that is predictable no matter how many there are.
The intuition
The documented summary has three lines:
- By default, every request is implicitly denied. The one exception is the account's root user, which has full access.
- An explicit Allow in a policy that applies to the request overrides that default.
- An explicit Deny in any applicable policy overrides every Allow.
So the evaluation is effectively: look for a matching Deny anywhere — if found, the answer is Deny. Otherwise look for a matching Allow — if found, and nothing else limits it, the answer is Allow. Otherwise, Deny, because nobody said yes.
Two consequences matter in practice. First, order does not matter. IAM is not a firewall rule list where the first match wins; a Deny in the last statement of the last policy beats an Allow in the first. Second, a Deny cannot be undone by adding permissions elsewhere. That makes Deny the right tool for guardrails: "nobody deletes production backups", whatever else they are granted.
Some policy types never grant anything; they only set a ceiling. AWS Organizations service control policies (SCPs) and resource control policies (RCPs), permissions boundaries and session policies work this way: if one of them applies and does not allow the action, the request is implicitly denied even if an identity-based policy allows it.
Watch it run
The animation follows the lesson's app on EC2. Instead of an access key in a config file, it receives temporary credentials from an IAM role. A GetObject call is checked: no explicit deny, an explicit allow in the role's policy — allowed. A buggy DeleteObject call finds neither, so the default deny applies. Then someone widens the policy to s3:* and adds an explicit deny on delete, and the deny wins.
Shared Responsibility & IAM
Step 1 of 10
The split first: AWS secures the hardware, network and hypervisor. What you configure — data, OS patches, who may call what — is yours.
The same interactive animation as the lesson — step through it with the controls.
The code
The Python below is a toy model of the documented rule for policies inside one account, not the AWS engine: it ignores conditions, principals and the guardrail policy types. Statements match an action and a resource, with * as a wildcard:
from fnmatch import fnmatchcase
def matches(statement, action, resource):
return (any(fnmatchcase(action, a) for a in statement["Action"]) and
any(fnmatchcase(resource, r) for r in statement["Resource"]))
def evaluate(statements, action, resource):
"""A toy model of the documented rule, not the AWS engine: an explicit
Deny wins, otherwise an explicit Allow, otherwise the default deny."""
hits = [s["Effect"] for s in statements if matches(s, action, resource)]
if "Deny" in hits:
return "explicit deny"
if "Allow" in hits:
return "allow"
return "implicit deny"
reader = [{"Effect": "Allow", "Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::photos-bucket/*"]}]
print(evaluate(reader, "s3:GetObject", "arn:aws:s3:::photos-bucket/cat.jpg")) # allow
print(evaluate(reader, "s3:DeleteObject", "arn:aws:s3:::photos-bucket/cat.jpg")) # implicit deny
print(evaluate(reader, "s3:GetObject", "arn:aws:s3:::payroll/2026.csv")) # implicit deny
widened = [{"Effect": "Allow", "Action": ["s3:*"], "Resource": ["*"]},
{"Effect": "Deny", "Action": ["s3:DeleteObject"],
"Resource": ["arn:aws:s3:::photos-bucket/*"]}]
print(evaluate(widened, "s3:DeleteObject", "arn:aws:s3:::photos-bucket/cat.jpg")) # explicit deny
print(evaluate(widened, "s3:PutObject", "arn:aws:s3:::photos-bucket/cat.jpg")) # allow
The habit that causes mistakes is reading policies like firewall rules, where the first matching rule decides:
def first_match_wins(statements, action, resource): # the firewall-rule habit
for s in statements:
if matches(s, action, resource):
return "allow" if s["Effect"] == "Allow" else "explicit deny"
return "implicit deny"
print(first_match_wins(widened, "s3:DeleteObject", "arn:aws:s3:::photos-bucket/cat.jpg"))
# allow -- wrong: the broad Allow came first
The model is then checked on 3,000 random policies for the properties the documentation implies: shuffling the statements never changes the answer, adding any Allow never rescues an explicit deny, and a policy with no Allow statements never allows anything. The first-match reading is counted separately:
import random
random.seed(10)
ACTIONS = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"]
PATTERNS = ACTIONS + ["s3:*", "s3:Get*", "*"]
RESOURCES = ["arn:aws:s3:::photos-bucket/a.jpg", "arn:aws:s3:::payroll/b.csv"]
RES_PATTERNS = RESOURCES + ["arn:aws:s3:::photos-bucket/*", "*"]
def random_statement():
return {"Effect": random.choice(["Allow", "Deny"]),
"Action": random.sample(PATTERNS, random.randint(1, 2)),
"Resource": random.sample(RES_PATTERNS, random.randint(1, 2))}
ok, order_bugs = True, 0
for _ in range(3000):
policy = [random_statement() for _ in range(random.randint(0, 5))]
action, resource = random.choice(ACTIONS), random.choice(RESOURCES)
verdict = evaluate(policy, action, resource)
shuffled = random.sample(policy, len(policy))
ok &= evaluate(shuffled, action, resource) == verdict # order never matters
extra = dict(random_statement(), Effect="Allow")
if verdict == "explicit deny": # no Allow can rescue it
ok &= evaluate(policy + [extra], action, resource) == "explicit deny"
ok &= evaluate([s for s in policy if s["Effect"] == "Deny"], # without any Allow,
action, resource) != "allow" # nothing is allowed
order_bugs += first_match_wins(policy, action, resource) != verdict
print(ok, order_bugs) # True 319
The first-match reading gives the wrong answer for 319 of the 3,000 random policies.
The complexity
The cost of IAM is not computation but reasoning: the more places a permission can come from, the harder it is to answer "who can do this?". A few documented details keep that manageable:
- Within one account, for most resources an Allow in either the identity-based policy or the resource-based policy is enough. IAM role trust policies and KMS key policies are exceptions: they must explicitly allow the principal.
- Across accounts, AWS evaluates the request in both accounts, and it is allowed only if both allow it: the caller's identity-based policy and the resource-based policy in the other account.
- Guardrails only subtract. SCPs, RCPs, permissions boundaries and session policies can turn an Allow into an implicit deny; they never grant access on their own.
Where it goes wrong
- Long-lived access keys on servers. An app on EC2 should use an IAM role through an instance profile. Its credentials are temporary and updated automatically, so there is no secret on disk to leak.
- Broad Allows "for now".
s3:*on*is easy to write and hard to take back. Start narrow; IAM Access Analyzer can generate a policy from the access activity recorded in CloudTrail, so you can widen with evidence. - Expecting an Allow to override a Deny. It never does. If access is denied despite an Allow, look for an explicit Deny or a guardrail policy that does not allow the action.
- Using the root user for daily work. It bypasses the default deny for its own account; keep it for the few tasks that require it.
How to say it in an interview
"Every request starts implicitly denied. AWS evaluates all the policies that apply — identity-based, resource-based, and guardrails like SCPs and permissions boundaries. If any of them has a matching explicit Deny, the request is denied, full stop. Otherwise it needs an explicit Allow, and the guardrails must not block it. Order doesn't matter, and an Allow can never override a Deny, which is why Deny is the tool for organisation-wide rules. For an app on EC2 I'd use a role with temporary credentials and a least-privilege policy."
The rest of the AWS module builds on this; VPCs, subnets and security groups covers the network side of the same responsibility.
Sources
- Shared Responsibility Model — AWS
- Policy evaluation: how requests are allowed or denied — IAM User Guide
- Cross-account policy evaluation logic — IAM User Guide
- Use an IAM role for applications on EC2 — IAM User Guide
- Security best practices in IAM — IAM User Guide