403 AccessDenied

AWS S3 bucket policy 'AccessDenied' for cross-account access

Server & Cloud Intermediate 👁 0 views 📅 May 25, 2026

A 403 AccessDenied on cross-account S3 access usually means the bucket policy or IAM role is missing the right combination of actions and principals. Here's how to fix it.

Quick answer

Check the bucket policy's Principal field — it must use the root ARN of the other account (e.g., arn:aws:iam::111111111111:root), not an IAM role ARN. Also verify the IAM role in the source account has s3:GetObject (or the needed action) and the role trust policy allows the source account's users to assume it.

Why you're getting a 403

You're trying to let an EC2 instance in Account A access a bucket in Account B. You set up a bucket policy in Account B granting access to Account A's IAM role — but you keep hitting 403 AccessDenied. The root cause is almost always one of three things: the bucket policy's Principal is wrong, the IAM role in Account A doesn't have the right S3 actions, or there's a trust policy mismatch. AWS evaluates cross-account S3 requests by checking both the bucket policy and the caller's IAM permissions. Both must allow the action. If you get a 403, it means one of them is blocking the request.

Fix steps

  1. Verify the bucket policy's Principal — Open the bucket in Account B, go to Permissions > Bucket Policy. The Principal must be the root account ARN of Account A, not a specific role. Example:
    "Principal": {
      "AWS": "arn:aws:iam::111111111111:root"
    }
    This grants access to any IAM entity in Account A that has the right permissions. If you set it to a role ARN, it only works if that role is the direct caller — but cross-account requests always use the root principal. You can restrict further with aws:SourceArn or aws:SourceAccount conditions.
  2. Check the IAM role in Account A — The role your EC2 instance assumes must have an S3 action policy attached. For example, to read objects:
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Action": "s3:GetObject",
          "Resource": "arn:aws:s3:::your-bucket-name/*"
        }
      ]
    }
    Don't forget the /* on the resource — without it, you can't access objects inside the bucket. This is a common gotcha.
  3. Validate the role trust policy — The role in Account A must allow the EC2 instance (or your IAM user) to assume it. On the role's Trust Relationships tab, ensure it has:
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
    If you're testing from an AWS CLI session logged in as an IAM user, change the principal to that user's ARN.
  4. Test with the root principal first — Before adding conditions, simplify the bucket policy to allow all access from Account A to narrow down the issue. Use:
    {
      "Effect": "Allow",
      "Principal": {"AWS": "arn:aws:iam::111111111111:root"},
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::your-bucket-name/*"
    }
    If this works, then start adding conditions like aws:SourceArn or aws:SourceAccount. If it still fails, the problem is not the bucket policy.
  5. Check for explicit Deny policies — Account A might have a service control policy (SCP) or a permissions boundary that denies S3 access to certain accounts. Also check Account B for bucket ACLs that might block cross-account access. S3 evaluates bucket policies and ACLs together — an ACL with DENY can override an Allow in the policy.
  6. Use the AWS CLI to debug — Run this from the EC2 instance in Account A, with the role assumed:
    aws s3 ls s3://your-bucket-name/ --debug
    The debug output shows the exact error message and the policies being evaluated. Look for lines like "AccessDenied" or "User: arn:aws:sts::111111111111:assumed-role/your-role/i-xyz". That ARN tells you who AWS thinks is calling. If it's not the role you expect, the trust policy or instance profile is wrong.

Alternative fixes

If the bucket policy still fails, switch to an IAM-based approach. Don't use a bucket policy at all. Instead, in Account B, create an IAM role that grants s3:GetObject on the bucket. Then give Account A's IAM role permission to sts:AssumeRole into that role. This is more complex but gives you fine-grained control. The downside: you now manage two roles instead of one.

If you're using CloudFront, the origin access identity (OAI) can bypass bucket policies entirely — but that only works for CloudFront, not direct S3 access.

Prevention tip

Always test with the most permissive policy first — root principal, all actions — before locking it down. Use the --debug flag on every S3 CLI call during setup. It'll save you hours. Also, use aws:SourceIp or aws:SourceVpc conditions instead of aws:SourceArn unless you must tie it to a specific resource — this avoids the pain of updating policies when resources change. And always check the IAM role's trust policy separately from the bucket policy — they're independent and both can block you.

Was this solution helpful?