Understanding IAM policy evaluation logic
Allow, deny, boundaries, SCPs, the order AWS evaluates them, and why your policy isn’t working.
The first time a perfectly correct-looking IAM policy denied my Lambda function access to a bucket, I burned an afternoon adding more Allow statements. None of them worked, because the problem wasn't a missing allow, it was an explicit deny buried in an SCP three accounts up. IAM evaluation isn't "do I have an allow somewhere." It's a specific, ordered decision process, and once you internalize it, most "why is this denied" mysteries solve themselves in minutes.
The default is deny
Every request starts denied. An identity has zero permissions until something explicitly grants them. This implicit deny is the baseline, and it's why a brand-new IAM user can do nothing at all. An Allow overrides the implicit deny; an explicit Deny overrides any allow. There is no "allow wins" mode anywhere in the system.
Explicit Deny always wins. If you can't grant access no matter how many Allow statements you add, stop adding allows and start hunting for a Deny.
The order of operations
For a request inside a single account, AWS evaluates policy types roughly in this sequence, and any explicit Deny at any stage ends the decision immediately:
- Organizations SCPs, set the maximum permissions an account can have. An SCP can't grant anything; it can only cap.
- Resource-based policies, e.g. an S3 bucket policy or KMS key policy.
- Identity-based policies, attached to the user, group, or role.
- Permissions boundaries, a ceiling on what an identity policy can grant.
- Session policies, passed during
AssumeRole/GetFederationToken.
The mental shortcut: a request is allowed only if it's permitted at every applicable layer and denied at none. Boundaries and SCPs are intersections (AND), not unions.
The one exception people miss
Within the same account, identity-based and resource-based policies are additive, an allow in either is enough. This is why you can grant a role access to a bucket from the role's policy or the bucket policy. But cross-account, you need both: the resource policy in the target account must allow the principal, and the principal's identity policy in the source account must allow the action. Forgetting the resource side is the single most common cross-account failure I see.
Reading a policy like the evaluator does
Here's a deny that overrides everything else, note it denies any S3 action outside a specific VPC endpoint:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowReadBucket",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::reports-prod",
"arn:aws:s3:::reports-prod/*"
]
},
{
"Sid": "DenyOutsideVPCE",
"Effect": "Deny",
"Action": "s3:*",
"Resource": "arn:aws:s3:::reports-prod/*",
"Condition": {
"StringNotEquals": { "aws:SourceVpce": "vpce-0a1b2c3d4e5f" }
}
}
]
}
If a request comes from outside that endpoint, the Deny fires and the Allow is irrelevant. The condition keys (aws:SourceVpce, aws:PrincipalOrgID, aws:SourceIp) are where most real-world access logic lives.
Stop guessing, simulate
The IAM Policy Simulator and the CLI tell you the exact verdict and the matched statement, so you don't have to reason it out by hand:
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::111122223333:role/report-reader \
--action-names s3:GetObject \
--resource-arns arn:aws:s3:::reports-prod/q2.csv \
--query 'EvaluationResults[0].{Decision:EvalDecision,By:MatchedStatements[0].SourcePolicyId}'
For requests that already failed, CloudTrail's errorCode distinguishes an explicit deny from an implicit one, and IAM Access Analyzer can flag policies that grant broader access than you intended.
Takeaways
- Everything starts as an implicit deny; an explicit
Denybeats everyAllowand ends evaluation immediately. - SCPs and permissions boundaries only cap permissions, they never grant, so they intersect (AND) with identity policies.
- Same-account identity and resource policies are additive; cross-account access needs an allow on both sides.
- When stuck, simulate with
simulate-principal-policyand check CloudTrail rather than adding more allows.