AWS AccessDenied messages are more informative than they look. Before changing any policy, read the three facts the message gives you.
User: arn:aws:sts::123456789012:assumed-role/AppRole/i-0abc123
↑ the principal, which identity actually made the call
is not authorized to perform: s3:PutObject
↑ the action. The exact API operation
on resource: arn:aws:s3:::acme-uploads/report.pdf
↑ the resource, including the object key
because no identity-based policy allows the s3:PutObject action
↑ the reason. This one names the cause outright
That last clause is newer and worth looking for. AWS now often states why, and the wording distinguishes the causes below.
First: confirm who you actually are
More AccessDenied investigations are wasted on this than on anything else.
aws sts get-caller-identity
{
"UserId": "AROAEXAMPLE:i-0abc123",
"Account": "123456789012",
"Arn": "arn:aws:sts::123456789012:assumed-role/AppRole/i-0abc123"
}
Check the account and the role. A surprising share of these errors are a correct policy in the wrong account, a stale AWS_PROFILE, or an EC2 instance using its instance role when you assumed you were using your own credentials.
env | grep -i aws # environment variables override the profile
aws configure list # shows which source each value came from
Environment variables beat the profile, so an AWS_ACCESS_KEY_ID left over from an earlier session silently overrides --profile.
Then: ask IAM directly
Rather than reasoning about the policies, simulate the call:
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/AppRole \
--action-names s3:PutObject \
--resource-arns arn:aws:s3:::acme-uploads/report.pdf \
--query 'EvaluationResults[].[EvalActionName,EvalDecision,MatchedStatements[].SourcePolicyId]'
[["s3:PutObject", "implicitDeny", null]]
implicitDeny means nothing allows it. explicitDeny means something actively forbids it, and MatchedStatements names the policy, which is the fastest route to a deny buried in an SCP or a boundary.
Note the simulator does not evaluate resource policies or SCPs in every case, so a pass here with a real failure points at one of those.
The six causes, in order of likelihood
1. No identity-based policy allows it
The default state: IAM denies everything not explicitly allowed. The message says because no identity-based policy allows.
aws iam list-attached-role-policies --role-name AppRole
aws iam list-role-policies --role-name AppRole
Add the action, scoped to the resource:
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:PutObjectAcl"],
"Resource": "arn:aws:s3:::acme-uploads/*"
}
Watch the ARN shape. Bucket-level and object-level actions take different resources: s3:ListBucket needs arn:aws:s3:::acme-uploads, while s3:PutObject needs arn:aws:s3:::acme-uploads/*. Using one where the other belongs is the single most common S3 policy mistake.
2. A resource policy denies or does not allow it
For cross-account access, both sides must permit the call. A bucket policy, KMS key policy, SQS queue policy or Lambda resource policy can allow or deny independently of IAM.
aws s3api get-bucket-policy --bucket acme-uploads \
--query Policy --output text | python3 -m json.tool
The special case worth knowing: KMS requires both the key policy and IAM, and an IAM policy granting kms:* achieves nothing on a key whose policy does not permit your principal. A PutObject to an SSE-KMS bucket therefore fails with an S3-shaped error caused by KMS.
3. An explicit deny
A single Deny anywhere beats every Allow. The message reads because an explicit deny in an identity-based policy.
aws iam get-role-policy --role-name AppRole --policy-name inline \
--query 'PolicyDocument.Statement[?Effect==`Deny`]'
Common sources: a policy denying actions outside a region, denying access unless MFA is present, or denying anything not carrying a required tag.
4. A service control policy
... with an explicit deny in a service control policy
That phrase is the giveaway, and it appears at the end of a long message where it is easy to miss. An SCP caps what is possible in the account regardless of IAM.
aws organizations list-policies-for-target --target-id 123456789012 \
--filter SERVICE_CONTROL_POLICY
You need Organizations access to check, which usually means asking whoever manages the management account. Region-restriction SCPs are a frequent cause, and they break global services unless exempted.
5. A permissions boundary
A boundary caps what a role can do even when its policies allow more. The message says because no permissions boundary allows.
aws iam get-role --role-name AppRole --query 'Role.PermissionsBoundary'
6. A session policy
When a role is assumed with --policy or --policy-arns, that session is limited further:
aws sts assume-role --role-arn "$ROLE" --role-session-name test \
--policy '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}'
The resulting credentials cannot write to S3 no matter what the role permits. Federated sessions from an identity provider frequently carry one of these.
Reading which cause applies
The because … clause maps directly:
| Clause | Cause |
|---|---|
no identity-based policy allows | Missing IAM permission |
explicit deny in an identity-based policy | A Deny in your IAM policy |
explicit deny in a service control policy | An SCP |
no permissions boundary allows | A permissions boundary |
explicit deny in a resource-based policy | Bucket, key or queue policy |
no session policy allows | Session policy on the assumed role |
If your error has no because clause, the API is an older one. Use the simulator.
Cross-account AssumeRole
User: arn:aws:iam::123456789012:user/ci is not authorized to perform:
sts:AssumeRole on resource: arn:aws:iam::210987654321:role/Deployer
Two separate permissions, and both are needed:
The caller needs sts:AssumeRole on that role ARN:
{ "Effect": "Allow", "Action": "sts:AssumeRole", "Resource": "arn:aws:iam::210987654321:role/Deployer" }
The role needs a trust policy accepting the caller:
aws iam get-role --role-name Deployer --query 'Role.AssumeRolePolicyDocument'
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::123456789012:user/ci" },
"Action": "sts:AssumeRole",
"Condition": { "StringEquals": { "sts:ExternalId": "acme-ci" } }
}
Missing either gives an identical error. And note the ExternalId condition, if the trust policy requires one, the caller must pass --external-id or the assume fails with the same message.
Find it in CloudTrail
Every denied call is recorded, with the full request context:
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=PutObject \
--start-time "$(date -u -v-1H +%Y-%m-%dT%H:%M:%SZ)" \
--query 'Events[?contains(CloudTrailEvent, `AccessDenied`)].CloudTrailEvent' \
--output text | python3 -m json.tool | head -40
The event includes userIdentity, requestParameters and errorMessage, which together settle arguments about what was actually requested, often revealing the call used a different resource ARN than anyone assumed.
A checklist
- Read the principal, action and resource from the message.
aws sts get-caller-identity. Right account, right role?env | grep -i aws. Is something overriding your profile?- Read the
because …clause; it names the cause. aws iam simulate-principal-policy. Implicit or explicit deny?- Check bucket-level vs object-level ARN shape for S3.
- SSE-KMS bucket → check the key policy, not just IAM.
- Cross-account → caller permission and trust policy, plus any
ExternalId. - Still unclear → CloudTrail has the full request.
Frequently Asked Questions
How do I read an AWS "is not authorized to perform" error?
It gives you four things: the principal ARN that made the call, the exact API action, the resource ARN including any object key, and increasingly a because … clause naming the cause. Start with the principal. Confirm with aws sts get-caller-identity that you are the identity you think you are, in the account you think you are. A large share of these errors turn out to be a correct policy in the wrong account or a stale environment variable overriding your profile.
Why does my IAM policy allow the action but the call still fails?
Six things can deny a request, and IAM is only one. A resource policy, bucket, KMS key, queue, can refuse independently. An explicit Deny anywhere overrides every Allow. A service control policy caps what is possible in the whole account. A permissions boundary caps what the role can do. And a session policy can restrict credentials further when a role is assumed with --policy. The because … clause in the error tells you which applies.
Why do I get AccessDenied on S3 when my policy looks right?
Most often the resource ARN shape. Bucket-level actions such as s3:ListBucket require arn:aws:s3:::bucket, while object-level actions such as s3:GetObject and s3:PutObject require arn:aws:s3:::bucket/*, and using one form where the other belongs denies the call with a policy that reads as correct. The second cause is an SSE-KMS bucket: writing to it needs kms:GenerateDataKey on the key, permitted by both the key policy and IAM.
What does "with an explicit deny in a service control policy" mean?
An SCP attached to your account or one of its parent organizational units forbids that action, and no IAM policy can override it. SCPs set a ceiling on what is possible. The phrase appears at the end of a long message where it is easy to overlook, and it is the only reliable indicator, since an implicit denial under an allow-list SCP gives no explanation at all. Checking requires Organizations access, so it usually means asking whoever manages the management account.
Why does sts:AssumeRole fail across accounts?
Because it needs permission on both sides. The calling identity requires sts:AssumeRole on the target role's ARN in its own IAM policy, and the target role requires a trust policy naming that caller as a principal. Missing either produces an identical error. If the trust policy includes an sts:ExternalId condition, the caller must also pass --external-id with the matching value, which fails the same way when omitted.
How do I find out exactly why a request was denied?
aws iam simulate-principal-policy with the action and resource ARN returns implicitDeny or explicitDeny, and for an explicit deny names the policy in MatchedStatements, which is much faster than reading policies by hand. It does not evaluate every resource policy or SCP, so a pass there with a real-world failure points at one of those. CloudTrail holds the complete request for any denied call, including the resource ARN actually used, which frequently differs from what everyone assumed.