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:

  1. Organizations SCPs, set the maximum permissions an account can have. An SCP can't grant anything; it can only cap.
  2. Resource-based policies, e.g. an S3 bucket policy or KMS key policy.
  3. Identity-based policies, attached to the user, group, or role.
  4. Permissions boundaries, a ceiling on what an identity policy can grant.
  5. 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 Deny beats every Allow and 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-policy and check CloudTrail rather than adding more allows.