Troubleshoot AWS IAM Access Denied Errors Step by Step

Close-up of a server rack with rows of small blue and green indicator lights

Photo by Tyler on Unsplash

To troubleshoot an AWS IAM access denied error, read the message first: it names the principal, the action, the resource and usually the policy type that denied it. Confirm the caller with aws sts get-caller-identity, then check that caller’s identity policies for the action. If they allow it, look for an explicit deny in an SCP, permissions boundary, session policy or resource policy.

This guide is for developers and platform engineers who hit AccessDenied, AccessDeniedException or UnauthorizedOperation and want a method instead of guesswork. It shows how to troubleshoot an AWS IAM access denied error in a fixed order, with the AWS CLI commands and a TypeScript script for each step, the permissions the troubleshooting itself needs, and where an AI CLI saves time. The order matters: most time is lost checking the wrong policy for the wrong principal.

How do you read an AWS access denied error message?

Most access denied messages follow one format, documented in the IAM User Guide:

Anatomy of the message

User: arn:aws:sts::123456789012:assumed-role/app-prod/i-0abc123   <- who (the principal)
is not authorized to perform: s3:GetObject                         <- the IAM action
on resource: arn:aws:s3:::invoices-prod/2026/10/INV-1042.pdf       <- the resource
because no identity-based policy allows the s3:GetObject action    <- which policy type

The last part tells you where to look. “Because no type policy allows” is an implicit deny: nothing granted the action. “With an explicit deny in a type policy” is an explicit deny: a Deny statement matched, and no Allow anywhere can override it. Since March 2026, the message can also include the ARN of the denying policy for SCPs, RCPs, permissions boundaries, session policies and identity-based policies, when the caller is in the same account or organization as the policy, as the AWS Security Blog post on policy ARNs in access denied errors explains.

Phrase in the error Where to look
no identity-based policy allows Policies attached to the user, group or role
explicit deny in a service control policy AWS Organizations SCPs on the account or its OUs
explicit deny in a resource control policy Organizations RCPs on the resource’s account
no permissions boundary allows The boundary policy set on the user or role
no session policy allows The policy passed when the role session was created
resource-based policy Bucket policy, key policy, queue policy, function policy
VPC endpoint policy The policy on the interface or gateway endpoint the call went through

Not every service uses this format. S3 returns a short AccessDenied for some requests, and EC2 often returns an encoded authorization message you decode in step 5.

Prerequisites

  • AWS CLI v2 configured with the failing profile and, separately, one that can read IAM (see the permissions section).
  • The full error text, including the request ID if you have it.
  • Node.js 18+ if you want to run the CloudTrail script.

How to troubleshoot an AWS IAM access denied error, step by step

  1. Confirm who is callingRun aws sts get-caller-identity with the same profile, environment variables or instance role the failing code uses. It needs no permissions and answers the most common surprise: the code runs as a different role than you think.
  2. Find the exact IAM actionTake it from the error. If the error is vague, map the API call to its IAM action (table below). API names and action names don’t always match.
  3. Check the identity policiesList what’s attached to the role and read the statements for that action and resource ARN, including any Condition.
  4. Simulate the requestRun the IAM policy simulator against the principal, action and resource to see which statement allows or denies.
  5. Hunt for explicit deniesIf identity policies allow it, check SCPs, RCPs, permissions boundaries, session policies, resource policies and VPC endpoint policies, in the order the error suggests.
  6. Fix with the smallest changeAdd only the missing action on the specific resource, retest, and remove anything you added while experimenting.

Find the missing IAM action from an AWS SDK error

In the AWS SDK for JavaScript v3, a denied call throws an error whose name is the error code and whose $metadata.httpStatusCode is usually 403 (some JSON-protocol services return 400). Log both, plus the request ID, instead of swallowing the error:

log-denied.ts

import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";

const client = new S3Client({});

interface AwsError {
  name?: string;
  message?: string;
  $metadata?: { httpStatusCode?: number; requestId?: string };
}

async function main(): Promise<void> {
  try {
    await client.send(new GetObjectCommand({ Bucket: "invoices-prod", Key: "2026/10/INV-1042.pdf" }));
    console.log("Allowed");
  } catch (err) {
    const e = err as AwsError;
    if (/AccessDenied|Unauthorized|Forbidden/i.test(e.name ?? "") || e.$metadata?.httpStatusCode === 403) {
      console.error(`Denied: ${e.name} (HTTP ${e.$metadata?.httpStatusCode})`);
      console.error(`Message: ${e.message}`);
      console.error(`Request ID: ${e.$metadata?.requestId}`);
      process.exit(2);
    }
    throw err;
  }
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
API call IAM action actually checked
S3 ListBuckets s3:ListAllMyBuckets
S3 ListObjectsV2 s3:ListBucket on the bucket ARN, not bucket/*
S3 HeadObject s3:GetObject
S3 GetObject on an SSE-KMS object s3:GetObject plus kms:Decrypt on the key
Lambda Invoke lambda:InvokeFunction
EC2 RunInstances with an instance profile ec2:RunInstances plus iam:PassRole on the role

If you have the code but not the policy, find the IAM actions your AWS SDK for JavaScript code needs from its Command imports, or paste it into the IAM policy generator for TypeScript and JavaScript code; there are also generators for boto3 Python code and Go SDK code. Treat the output as a draft and scope the resources before attaching it.

Check policies and simulate the request

List what’s attached to the role, then read the relevant policy version:

Inspect and simulate

aws iam list-attached-role-policies --role-name app-prod
aws iam list-role-policies --role-name app-prod
aws iam get-policy --policy-arn arn:aws:iam::123456789012:policy/app-prod-s3 --query Policy.DefaultVersionId
aws iam get-policy-version --policy-arn arn:aws:iam::123456789012:policy/app-prod-s3 --version-id v3

# Would the role be allowed? Shows allowed, explicitDeny or implicitDeny per action
aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::123456789012:role/app-prod \
  --action-names s3:GetObject kms:Decrypt \
  --resource-arns arn:aws:s3:::invoices-prod/2026/10/INV-1042.pdf \
  --query "EvaluationResults[].{action:EvalActionName,decision:EvalDecision}"

According to the IAM documentation, the simulator evaluates identity policies, permissions boundaries and SCPs, but it doesn’t support resource control policies, and outside the console it doesn’t fetch resource-based policies for you (pass them with --resource-policy). A simulator “allowed” plus a real-world denial therefore points at a resource policy, an RCP, a VPC endpoint policy or a session policy.

Debug an explicit deny in AWS

An explicit deny wins over every allow, so adding permissions won’t help. Work through the likely sources:

  • SCPs and RCPs: common patterns deny regions outside an allow-list (aws:RequestedRegion), deny actions without MFA, or deny access from outside the organization. Only the management account or a delegated administrator can read them, so you may need your platform team. When a deny-without-MFA rule is the cause, check whether the caller has an MFA device at all; a script can find IAM users without MFA across the account.
  • Permissions boundaries: aws iam get-role --role-name app-prod --query Role.PermissionsBoundary shows whether one is set. The effective permissions are the intersection of the identity policies and the boundary.
  • Resource policies: bucket policies that deny unless aws:SourceVpce or aws:SecureTransport matches are frequent causes. Read the bucket policy, key policy or queue policy. To see which buckets carry an HTTPS-only statement, run the script to find S3 buckets whose bucket policy doesn’t require HTTPS.
  • Encoded EC2 messages: run aws sts decode-authorization-message --encoded-message <text> --query DecodedMessage --output text. The decoded JSON shows the matched statements and the request context. It needs sts:DecodeAuthorizationMessage.

For IAM API calls, AWS is also previewing an authorization ID in denied responses that you pass to GetRequestAuthorizationDetails to see every evaluated policy; support is rolling out service by service.

Check an assumed role’s effective permissions

When the principal is assumed-role/…, three extra things shape what it can do. A session policy passed to AssumeRole narrows the role’s permissions to the intersection of both. Session tags can make aws:PrincipalTag conditions pass or fail. And “User: … is not authorized to perform: sts:AssumeRole” means the role’s trust policy doesn’t allow your caller, which is a different fix from the role’s permissions. Simulate against the role ARN, not the session ARN, and remember the simulator can’t see a session policy you didn’t pass to it. To script these checks, the example to check AWS assumed role permissions of your current IAM role resolves the role behind a session, lists its policies and simulates actions.

Search CloudTrail for denied calls

When the error was swallowed by an app or happened in a scheduled job, CloudTrail event history has it. This script uses the AWS SDK for JavaScript v3 to list denied management events from the last hour, optionally filtered to one service:

denied-calls.ts

import { CloudTrailClient, paginateLookupEvents } from "@aws-sdk/client-cloudtrail";
import type { LookupAttribute } from "@aws-sdk/client-cloudtrail";

const minutes = Number(process.argv[2] ?? "60");
const eventSource = process.argv[3]; // for example s3.amazonaws.com

interface TrailRecord {
  eventTime?: string;
  eventSource?: string;
  eventName?: string;
  errorCode?: string;
  errorMessage?: string;
  userIdentity?: { arn?: string };
}

const client = new CloudTrailClient({});

async function main(): Promise<void> {
  const endTime = new Date();
  const startTime = new Date(endTime.getTime() - minutes * 60 * 1000);
  const lookupAttributes: LookupAttribute[] = eventSource
    ? [{ AttributeKey: "EventSource", AttributeValue: eventSource }]
    : [];

  let found = 0;
  for await (const page of paginateLookupEvents(
    { client },
    { StartTime: startTime, EndTime: endTime, LookupAttributes: lookupAttributes },
  )) {
    for (const event of page.Events ?? []) {
      if (!event.CloudTrailEvent) continue;
      const record = JSON.parse(event.CloudTrailEvent) as TrailRecord;
      if (!/AccessDenied|UnauthorizedOperation/i.test(record.errorCode ?? "")) continue;
      found += 1;
      console.log(
        `${record.eventTime}  ${record.userIdentity?.arn ?? "unknown"}  ` +
          `${record.eventSource}:${record.eventName}  ${record.errorCode}  ${record.errorMessage ?? ""}`,
      );
    }
  }
  console.log(`${found} denied calls in the last ${minutes} minutes`);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
Run it

npm install @aws-sdk/client-cloudtrail
AWS_PROFILE=security-readonly AWS_REGION=eu-west-1 npx tsx denied-calls.ts 60 s3.amazonaws.com

Event history covers management events for the last 90 days in one region, and events can take several minutes to appear. S3 object-level calls such as GetObject are data events and only appear if a trail records them.

Permissions needed to troubleshoot

Troubleshooting needs read access to IAM, CloudTrail and a few helpers. This policy grants it without granting any change:

iam-troubleshooting-readonly.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadIamAndSimulate",
      "Effect": "Allow",
      "Action": [
        "iam:GetRole",
        "iam:GetPolicy",
        "iam:GetPolicyVersion",
        "iam:GetRolePolicy",
        "iam:ListAttachedRolePolicies",
        "iam:ListRolePolicies",
        "iam:SimulatePrincipalPolicy",
        "iam:GetContextKeysForPrincipalPolicy"
      ],
      "Resource": "*"
    },
    {
      "Sid": "TrailsAndHelpers",
      "Effect": "Allow",
      "Action": [
        "cloudtrail:LookupEvents",
        "sts:DecodeAuthorizationMessage",
        "access-analyzer:ValidatePolicy",
        "organizations:DescribeOrganization"
      ],
      "Resource": "*"
    }
  ]
}

When you write the fix, grant the one missing action on the one resource. NIST defines least privilege as giving each entity only the access it needs to do its job, and that’s the test for every statement you add. aws accessanalyzer validate-policy --policy-type IDENTITY_POLICY --policy-document file://fix.json flags errors and overly broad statements before you attach it. For the full checklist, see how to review an IAM policy for least privilege.

Explain an IAM access denied in plain English with ChatWithCloud

ChatWithCloud is a command-line tool that answers questions about your AWS account in plain English. Point it at a profile that can read IAM and paste the error: “Why is app-prod denied s3:GetObject on invoices-prod? Check its policies, boundary and the bucket policy.” It writes AWS SDK code to read those policies, runs it on your machine, and explains what it finds. Follow-ups like “Show denied calls for this role in the last hour” pull from CloudTrail. The guide to connect ChatWithCloud to your AWS account with profiles, SSO or roles covers setup, and how ChatWithCloud turns questions into SDK calls explains what runs where.

It also hits access denied errors itself when your profile is read-only; the error goes back to the model, which retries with a different approach or tells you which permission is missing. For a wider review of who can do what, analyze your AWS security posture with an AI CLI, and for failures that aren’t permissions at all, the workflow to troubleshoot AWS infrastructure from the terminal applies.

Limits: it can’t read SCPs unless your profile can, it can misread a policy, and generated code runs without a confirmation step, so never give it a profile that can edit IAM while you’re exploring. The ChatWithCloud security page on credentials and data lists what is sent to the model.

Common mistakes

  • Editing the policy of the role you think is calling, not the one get-caller-identity shows.
  • Granting s3:ListBucket on bucket/* or s3:GetObject on the bucket ARN; each needs the other resource form.
  • Waiting for an allow to fix an explicit deny. It never will.
  • Fixing with "Action": "*" to “test” and forgetting to remove it. The script to find IAM policies that grant admin access catches the ones left behind.

Frequently asked questions

What’s the difference between an explicit and implicit deny?

An implicit deny means no policy allowed the action, so you add an Allow. An explicit deny means a Deny statement matched, and you have to change or scope that statement; no allow can override it.

Why does S3 return 403 instead of 404 for a missing object?

Without s3:ListBucket on the bucket, S3 returns 403 Access Denied for keys that don’t exist, so it doesn’t reveal which keys exist. Grant s3:ListBucket to get 404 instead.

How do I check an assumed role’s effective permissions?

Run simulate-principal-policy against the role ARN, then account for anything the simulator can’t see: session policies, RCPs, VPC endpoint policies and resource policies you didn’t pass in.

Can AI explain an AWS access denied error?

Yes, if it can read the relevant policies. The error text alone tells you the policy type; the explanation needs the actual statements, which is why the AI CLI reads them from your account rather than guessing.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud