OnCallReady

Lesson 35.10 · AWS I: CLI, IAM, S3 & KMS · 21 min read

Policy evaluation: how AWS decides, and reading AccessDenied

In plain words

Picture a nightclub with several bouncers. The club's owner has a rule list at the entrance (SCPs), the room you want to enter may have its own list (a resource policy), and you carry a wristband that says what you may do (your identity policy). Some people also wear a second band that caps what they may ever do (a permissions boundary).

If any list says "absolutely not" for you, you are out, whatever the others say. Otherwise you need at least one yes from the lists that count, and no cap may forbid it. When you are refused, the bouncer even tells you which list said no.

How AWS decides

You will spend more on-call time on AccessDenied than on any other AWS error, and most of that time goes to guessing. You do not need to guess: AWS evaluates every request with a fixed algorithm, and since 2024 the error message tells you which kind of policy stopped you. This lesson is that algorithm, the message, and the simulator that lets you test a policy before someone tests it in production.

Need to know: everything is denied by default. A request is allowed only if some applicable policy allows it and no applicable policy denies it - an explicit Deny anywhere wins over every Allow. Guardrails cap what allows can do: an SCP must allow the action, a permissions boundary must allow it, a session policy must allow it. Read the end of the AccessDenied message: "because no identity-based policy allows" means nothing allows it; "with an explicit deny in a service control policy: arn:..." names the culprit.

The algorithm

For one request (a principal, an action, a resource, a context), AWS collects every policy that applies and walks this list. The first step that decides, decides:

StepQuestionIf no
1. Explicit denyDoes any applicable policy - SCP, RCP, resource-based, identity-based, boundary, session - have a matching Deny?(if yes: denied, explicitly)
2. OrganizationsDoes an SCP (for the principal's account) and an RCP (for the resource's account) allow it?denied: "no service control policy allows"
3. Resource-based policyDoes the resource's policy allow this principal? (same account: that can be enough on its own)carry on
4. Identity-based policiesDoes a policy on the user / role (or its groups) allow it?denied: "no identity-based policy allows"
5. Permissions boundaryIf the principal has one, does it allow it?denied: "no permissions boundary allows"
6. Session policyIf the session was created with one, does it allow it?denied: "no session policy allows"

Then: allowed. Three consequences you will use every week:

KMS is the exception you meet in the KMS lesson: a key's own policy must allow access, either directly or by delegating to IAM - an identity policy alone is never enough.

Reading AccessDenied

The setup gave you a second identity: the IAM user try-reports (profile try-reports), which has no policies at all, and a bucket with one report in it.

$ cd ~/oncall-lab/labs/aws
$ aws sts get-caller-identity --profile try-reports --query Arn --output text
arn:aws:iam::111122223333:user/try-reports
$ aws s3 cp s3://try-reports-111122223333/daily/2026-10-07.csv - --profile try-reports
fatal error: An error occurred (403) when calling the HeadObject operation: Forbidden
$ aws s3api get-object --bucket try-reports-111122223333 --key daily/2026-10-07.csv /tmp/r.csv --profile try-reports
aws: [ERROR]: An error occurred (AccessDenied) when calling the GetObject operation: User: arn:aws:iam::111122223333:user/try-reports is not authorized to perform: s3:GetObject on resource: "arn:aws:s3:::try-reports-111122223333/daily/2026-10-07.csv" because no identity-based policy allows the s3:GetObject action

Two lessons in three commands. First: aws s3 cp starts with a HeadObject, and a HEAD response has no body, so all it can say is (403) ... Forbidden. When a high-level command hides the reason, repeat the call with s3api. Second, the message itself:

User: arn:aws:iam::111122223333:user/try-reports          WHO   (the principal that signed)
is not authorized to perform: s3:GetObject                WHAT  (the action)
on resource: "arn:aws:s3:::try-reports-.../daily/..."     ON    (the resource ARN)
because no identity-based policy allows the s3:GetObject action    WHY  (the policy type)

Who is the first thing to check - a surprising number of AccessDenied tickets are the wrong profile or a role session nobody expected. The reason phrases and where they send you:

The message ends withLook at
because no identity-based policy allows the X actionthe user's or role's policies (and its groups)
with an explicit deny in an identity-based policy: arn:...that policy's Deny statement and its conditions
because no permissions boundary allows the X actionthe principal's permissions boundary
because no service control policy allows / with an explicit deny in a service control policy: arn:...the organization's SCPs (a different team, a different account)
with an explicit deny in a resource-based policythe bucket / key / queue policy
because no resource-based policy allows the X actionthe resource policy - for KMS, the key policy
because no session policy allows the X actionthe policy passed when the role was assumed
because public policies are prevented by the BlockPublicPolicy settingS3 Block Public Access

S3 quotes the resource and adds , with policy ARN: before the ARN of a denying policy; IAM, STS and KMS write ... policy: arn:.... Cross-account requests from outside your organization get the old bare Access Denied: AWS does not explain its decisions to strangers.

Now give the user what it needs - and only that:

$ cat try/read-reports.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "ReadReports",
            "Effect": "Allow",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": "arn:aws:s3:::try-reports-111122223333/*"
        }
    ]
}
$ aws iam put-user-policy --user-name try-reports --policy-name read-reports --policy-document file://try/read-reports.json
$ aws s3 cp s3://try-reports-111122223333/daily/2026-10-07.csv - --profile try-reports
date,orders,revenue
2026-10-07,1832,48211.40
$ aws s3api get-object --bucket try-reports-111122223333 --key daily/2026-10-08.csv /tmp/r.csv --profile try-reports
aws: [ERROR]: An error occurred (AccessDenied) when calling the GetObject operation: User: arn:aws:iam::111122223333:user/try-reports is not authorized to perform: s3:ListBucket on resource: "arn:aws:s3:::try-reports-111122223333" because no identity-based policy allows the s3:ListBucket action
$ aws s3 ls s3://try-reports-111122223333/daily/ --profile try-reports
aws: [ERROR]: An error occurred (AccessDenied) when calling the ListObjectsV2 operation: User: arn:aws:iam::111122223333:user/try-reports is not authorized to perform: s3:ListBucket on resource: "arn:aws:s3:::try-reports-111122223333" because no identity-based policy allows the s3:ListBucket action

A key that does not exist gives AccessDenied, not NoSuchKey, when you may not list the bucket: S3 will not confirm that an object is missing to someone who cannot list. If a job "suddenly" gets 403 for a file that simply was not uploaded yet, this is why. Granting s3:ListBucket (on the bucket ARN) turns it into a proper 404.

An explicit deny wins

Someone attaches a guardrail policy: deny reading reports outside eu-west-1.

$ cat try/deny-outside-dr.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "OnlyFromDR",
            "Effect": "Deny",
            "Action": [
                "s3:GetObject",
                "s3:DeleteObject"
            ],
            "Resource": "arn:aws:s3:::try-reports-111122223333/*",
            "Condition": {
                "StringNotEquals": {
                    "aws:RequestedRegion": "eu-west-1"
                }
            }
        }
    ]
}
$ aws iam create-policy --policy-name try-deny-outside-dr --policy-document file://try/deny-outside-dr.json --query Policy.Arn --output text
arn:aws:iam::111122223333:policy/try-deny-outside-dr
$ aws iam attach-user-policy --user-name try-reports --policy-arn arn:aws:iam::111122223333:policy/try-deny-outside-dr
$ aws s3 cp s3://try-reports-111122223333/daily/2026-10-07.csv - --profile try-reports
fatal error: An error occurred (403) when calling the HeadObject operation: Forbidden
$ aws s3api get-object --bucket try-reports-111122223333 --key daily/2026-10-07.csv /tmp/r.csv --profile try-reports
aws: [ERROR]: An error occurred (AccessDenied) when calling the GetObject operation: User: arn:aws:iam::111122223333:user/try-reports is not authorized to perform: s3:GetObject on resource: "arn:aws:s3:::try-reports-111122223333/daily/2026-10-07.csv" with an explicit deny in an identity-based policy, with policy ARN: arn:aws:iam::111122223333:policy/try-deny-outside-dr
$ aws iam detach-user-policy --user-name try-reports --policy-arn arn:aws:iam::111122223333:policy/try-deny-outside-dr

The inline allow is still there; the deny won anyway, and the message named the policy's ARN. The condition decided it: aws:RequestedRegion is the bucket's Region, eu-central-1, which is StringNotEquals eu-west-1.

Guardrails only limit: permissions boundaries

A permissions boundary is a managed policy set as the maximum a user or role can ever have. Teams use them to let developers create roles for their apps without being able to create an admin role: "you may create roles, but only with this boundary".

$ aws iam put-user-permissions-boundary --user-name try-reports --permissions-boundary arn:aws:iam::aws:policy/IAMReadOnlyAccess
$ aws s3api get-object --bucket try-reports-111122223333 --key daily/2026-10-07.csv /tmp/r.csv --profile try-reports
aws: [ERROR]: An error occurred (AccessDenied) when calling the GetObject operation: User: arn:aws:iam::111122223333:user/try-reports is not authorized to perform: s3:GetObject on resource: "arn:aws:s3:::try-reports-111122223333/daily/2026-10-07.csv" because no permissions boundary allows the s3:GetObject action
$ aws iam delete-user-permissions-boundary --user-name try-reports

The user's own policy allows s3:GetObject; the boundary (IAM read-only) does not, so the effective permission is the intersection: nothing.

Test before you grant: the policy simulator

aws iam simulate-principal-policy evaluates a principal's policies against actions and resources without making the calls, and says which statement decided:

$ aws iam simulate-principal-policy --policy-source-arn arn:aws:iam::111122223333:user/try-reports --action-names s3:GetObject s3:PutObject s3:ListBucket --resource-arns 'arn:aws:s3:::try-reports-111122223333/daily/2026-10-07.csv' --query 'EvaluationResults[].[EvalActionName,EvalDecision]' --output table
-----------------------------------
|     SimulatePrincipalPolicy     |
+----------------+----------------+
|  s3:GetObject  |  allowed       |
|  s3:PutObject  |  implicitDeny  |
|  s3:ListBucket |  implicitDeny  |
+----------------+----------------+
$ aws iam simulate-principal-policy --policy-source-arn arn:aws:iam::111122223333:user/try-reports --action-names s3:GetObject --resource-arns 'arn:aws:s3:::try-reports-111122223333/daily/2026-10-07.csv' --query 'EvaluationResults[0].MatchedStatements'
[
    {
        "SourcePolicyId": "read-reports",
        "SourcePolicyType": "IAM Policy",
        "StartPosition": {
            "Line": 4,
            "Column": 9
        },
        "EndPosition": {
            "Line": 11,
            "Column": 10
        }
    }
]

Two things to know about it: it uses the principal's identity policies, boundary and the SCPs, but not resource policies unless you pass one with --resource-policy; and conditions on keys you did not supply show up in MissingContextValues instead of matching. --context-entries supplies them:

$ aws iam simulate-custom-policy --policy-input-list '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Condition":{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}}]}' --action-names s3:GetObject --context-entries ContextKeyName=aws:SourceIp,ContextKeyValues=198.51.100.24,ContextKeyType=ip --query 'EvaluationResults[0].EvalDecision' --output text
implicitDeny

Conditions you will meet

KeyWhat it isClassic use
aws:SourceIpthe public IP AWS sees (your NAT gateway, not 10.x / 192.168.x)office / VPN allow-lists - and why they break behind a new NAT
aws:RequestedRegionthe Region the request goes toRegion guardrails in SCPs
aws:MultiFactorAuthPresenttrue in sessions created with MFAprotect destructive actions
aws:SecureTransportfalse for plain HTTPDeny in bucket policies
aws:PrincipalTag/<k>, aws:ResourceTag/<k>tags on the caller / the resourceABAC
aws:PrincipalOrgIDthe caller's organization"anyone in our org" in resource policies
aws:SourceVpcethe VPC endpoint the request came throughprivate-only buckets

When the request does not carry a key at all, IAM treats the values as "not matching": a positive operator (StringEquals, IpAddress, Bool) is false, and a negated one (StringNotEquals, NotIpAddress, ArnNotLike) is true. So an Allow that needs aws:SourceVpce silently grants nothing to callers outside a VPC endpoint, while a Deny with "StringNotEquals": {"s3:x-amz-server-side-encryption": "aws:kms"} also denies uploads that send no encryption header at all - which is usually the point. ...IfExists makes a missing key count as a match, and Null tests for presence explicitly.

In an interview: "Walk me through how AWS evaluates a request." - "Default deny. Any explicit Deny in any applicable policy wins. Then the organization's SCPs must allow the action, then a resource policy or an identity policy must allow it - in the same account either is enough, across accounts both are needed - and permissions boundaries and session policies, if present, must allow it too. The AccessDenied message names the policy type that decided, and simulate-principal-policy tests it without calling the service."

You can now: run the evaluation algorithm in your head, read an AccessDenied message field by field and go straight to the right policy, explain why an explicit deny or a boundary beats an allow, use the policy simulator with context entries, and spot conditions that silently do not apply.

Why it helps

AccessDenied is the error you will see most in AWS, and it is also the one people guess at most. The message tells you who made the request, which action, which resource, and which kind of policy decided. Reading it like a stack trace turns a guessing game into a two-minute fix.

Knowing the evaluation order also stops dangerous fixes: adding AdministratorAccess because "something" denied is how accounts end up wide open. The simulator lets you prove the fix before you apply it, which is how changes get approved in a real team.

Commands in this lesson

cd aws cat

FAQ

Why did I get AccessDenied instead of NoSuchKey?

Without s3:ListBucket on the bucket, S3 does not tell you whether a key exists, so a GetObject for a missing key returns AccessDenied (403) instead of NoSuchKey (404). With ListBucket you get the honest 404. It protects the names of objects from people who may not list them.

If my policy allows it, why is it still denied?

Something else limits it: an explicit Deny anywhere (identity, resource, boundary, SCP), an SCP that does not allow the action, a permissions boundary or session policy that does not include it, or a resource policy (a KMS key policy, for example) that does not delegate to IAM. The reason phrase at the end of the message names which.

What is the difference between a permissions boundary and an SCP?

Both only limit, never grant. A boundary is set on one user or role and caps what that principal can ever have, typically so developers can create roles for their apps without escalating. An SCP is set by AWS Organizations on accounts or OUs and caps every principal in them, except the management account.

How do conditions with a missing key behave?

If the request does not have the key, positive operators like StringEquals or IpAddress are false, so an Allow does not apply. Negated operators like StringNotEquals or NotIpAddress are true, so a Deny with them does apply. The IfExists suffix makes a missing key count as a match. Test with the simulator when in doubt.

Does the simulator call the service?

No. simulate-principal-policy evaluates the principal's real policies (and optionally a resource policy and context keys you pass) and returns allowed, explicitDeny or implicitDeny per action and resource, with the statements that matched. simulate-custom-policy does the same for a document you have not attached yet. It changes nothing, so it is safe in production.

In an interview Mid

Walk me through how AWS evaluates a request.

Default deny. AWS collects every applicable policy, and any explicit Deny wins immediately. Then the organization's SCPs must allow the action, then an identity-based or resource-based policy must allow it: in the same account either is enough, across accounts both sides must allow it. If the principal has a permissions boundary or the session has session policies, they must allow it too. The AccessDenied message names the policy type that decided ("because no identity-based policy allows" or "with an explicit deny in a service control policy"), and aws iam simulate-principal-policy tests the decision without calling the service.

Also asked: What is the difference between a permissions boundary and an SCP? · How does cross-account access differ from same-account access? · How do you debug an AccessDenied in AWS?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.