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:
| Step | Question | If no |
|---|---|---|
| 1. Explicit deny | Does any applicable policy - SCP, RCP, resource-based, identity-based, boundary, session - have a matching Deny? | (if yes: denied, explicitly) |
| 2. Organizations | Does 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 policy | Does the resource's policy allow this principal? (same account: that can be enough on its own) | carry on |
| 4. Identity-based policies | Does a policy on the user / role (or its groups) allow it? | denied: "no identity-based policy allows" |
| 5. Permissions boundary | If the principal has one, does it allow it? | denied: "no permissions boundary allows" |
| 6. Session policy | If the session was created with one, does it allow it? | denied: "no session policy allows" |
Then: allowed. Three consequences you will use every week:
- Deny beats allow, and an SCP deny beats your
AdministratorAccess. - Guardrails never grant. SCPs, boundaries and session policies only limit: allowing
s3:*in a boundary gives a role nothing until an identity policy grants it. - Cross-account needs both sides. In the same account, an allow in the identity policy or the resource policy is enough. When the principal and the resource are in different accounts, the identity policy (in the caller's account) and the resource policy (in the resource's account) must both allow it.
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 with | Look at |
|---|---|
| because no identity-based policy allows the X action | the 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 action | the 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 policy | the bucket / key / queue policy |
| because no resource-based policy allows the X action | the resource policy - for KMS, the key policy |
| because no session policy allows the X action | the policy passed when the role was assumed |
| because public policies are prevented by the BlockPublicPolicy setting | S3 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
| Key | What it is | Classic use |
|---|---|---|
aws:SourceIp | the 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:RequestedRegion | the Region the request goes to | Region guardrails in SCPs |
aws:MultiFactorAuthPresent | true in sessions created with MFA | protect destructive actions |
aws:SecureTransport | false for plain HTTP | Deny in bucket policies |
aws:PrincipalTag/<k>, aws:ResourceTag/<k> | tags on the caller / the resource | ABAC |
aws:PrincipalOrgID | the caller's organization | "anyone in our org" in resource policies |
aws:SourceVpce | the VPC endpoint the request came through | private-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-policytests 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.