When an AWS call fails with AccessDenied, the error message usually names the type of policy that refused it. IAM (Identity and Access Management, the AWS permissions service) checks several policy layers on every request, so editing the wrong one can waste hours. The clause at the end of the message, such as “because no identity-based policy allows the action” or “with an explicit deny in a service control policy”, tells you which layer to fix. A service control policy (SCP) is a guardrail set for a whole organization in AWS Organizations. Read the principal (the user or role making the call), the action, the resource and that closing clause. Then confirm the decision with the policy simulator and CloudTrail, the AWS log of API calls.
What’s happening
Every AWS API call runs through one evaluation: an explicit Deny anywhere wins; otherwise the request needs an Allow that survives every layer that applies to it. The layers, in the order they usually bite: service control policies from AWS Organizations (member accounts only), the permission boundary if the principal has one, session policies if the credentials came from AssumeRole with a scoped-down policy, and finally the identity-based and resource-based policies everyone actually looks at. For some services — KMS is the famous one — the resource policy must participate: an IAM policy alone cannot grant kms:Decrypt unless the key policy delegates to IAM.
The error message encodes which layer said no. “No identity-based policy allows” means nothing granted the action — the boring, common case. “Explicit deny in a service control policy” means the account-level guardrail fired and no amount of IAM editing in the member account will fix it. “Explicit deny in a resource-based policy” points at a bucket policy, key policy, or queue policy. “Because no permissions boundary allows” means the identity policy grants it but the boundary caps it. Since the clause names the layer, most Access Denied tickets are solved by reading the whole error instead of the first line.
The second trap is identity. Half of the Access Denied tickets we triage are the right policy evaluated against the wrong principal — a developer testing with a stale CLI profile, a container running under the task role instead of the execution role, or a Lambda using its own role while the engineer edits an unrelated user.
What to check first
- Who actually made the call. Run
aws sts get-caller-identityin the exact environment that failed. An assumed-role ARN looks likearn:aws:sts::111122223333:assumed-role/RoleName/session— the policy to inspect is onRoleName, not on any user. - The full error text. Capture the entire message including the because-clause; it names the failing policy type.
- The resource ARN in the error.
s3:ListBucketneeds the bucket ARN,s3:GetObjectneedsarn:aws:s3:::bucket/*— an ARN shape mismatch is the most common self-inflicted denial. - Whether an SCP applies. If the account sits in an organization, region and service denies frequently come from the parent, not the account. Our SCP baseline article covers what those guardrails typically block.
- Encryption on the resource. An S3 object under SSE-KMS returns Access Denied when the caller lacks
kms:Decrypt, even with full S3 permissions.
How to verify
Confirm the principal, then replay the decision:
# Who am I, really?
aws sts get-caller-identity
# Simulate the exact call against the real attached policies
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::111122223333:role/app-role \
--action-names s3:GetObject \
--resource-arns arn:aws:s3:::prod-assets/config.json
The simulator returns EvalDecision per action. In a member account it includes the organization decision (OrganizationsDecisionDetail.AllowedByOrganizations), and it honors permission boundaries — but it does not automatically pull in resource policies, so pass --resource-policy when a bucket or queue policy is suspect. The console version lives under IAM > Policy simulator.
Then find the real denied event in CloudTrail — console path CloudTrail > Event history, filter by user name or event name, or from the CLI:
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=GetObject \
--max-results 20 \
--query 'Events[].CloudTrailEvent' --output text | grep -i accessdenied
The event record shows the exact userIdentity, the errorCode (AccessDenied or Client.UnauthorizedOperation), and often an errorMessage matching the because-clause. Note that S3 data events (GetObject, PutObject) only appear if data-event logging is enabled on the trail; management events are always in Event history for 90 days. The evaluation-logic reference in the IAM documentation is worth keeping open while you read.
The fix
- Fix the ARN shape first. If the identity policy grants the action but the resource in the error does not match the
Resourceelement — bucket vsbucket/*, missing region or account ID in the ARN, wrong partition — correct the ARN. This resolves more denials than any other change. - Add the missing Allow, narrowly. When the clause says “no identity-based policy allows,” add the specific action on the specific resource to the role’s policy. Resist
*— it fixes the ticket and fails the next audit. - Chase the explicit deny to its layer. For an SCP deny, the change happens in the management account, usually against a region or service guardrail. For a resource-policy deny, read the bucket/key/queue policy for
Denystatements with conditions (aws:SourceVpce,aws:SecureTransport, org-ID conditions) that your call fails. - Handle KMS as its own problem. Grant
kms:Decrypt(andkms:GenerateDataKeyfor writes) on the specific key, and confirm the key policy either grants the principal or contains the standard statement enabling IAM policies for the account. - Check the boundary and session policy last. If the principal has a permission boundary, the effective permission is the intersection — the identity policy alone proves nothing. Same for credentials minted with a session policy.
When this is the wrong move
If the same denial keeps recurring across many roles, stop patching individual policies — the account needs a permission model, not another exception. And if the denied caller is an unfamiliar principal probing S3 or IAM, that is a security event, not a bug: leave the deny in place and investigate via your CloudTrail and GuardDuty baseline before anything else.
Common mistakes
- Editing the policy of a user while the workload runs under a role — always start from
get-caller-identity. - Reading only the first line of the error and missing the because-clause that names the failing layer.
- Assuming the policy simulator covers resource policies by default — it needs them supplied explicitly.
- Granting
Action: "*"“temporarily” to unblock a deploy; temporary wildcards are the most permanent objects in AWS.
Prevention
- Standardize on the enhanced error clause in runbooks: triage starts with principal, action, resource, layer.
- Keep CloudTrail on in every account with data events on sensitive buckets, so denials are searchable after the fact.
- Prefer roles over users everywhere so “who made the call” has one answer per workload.
- Review new policies with
simulate-principal-policyin CI before they ship, not after they deny.
Most Access Denied incidents take ten minutes once the reading order is principal, error clause, simulator, CloudTrail — and hours when someone starts by editing JSON. If nobody on your team truly owns the AWS environment end to end, that is the root of most of these problems — Stack Harbor runs AWS accounts daily under AWS Operations, and you can book an AWS review.