How to Fix AWS S3 Access Denied Error: A Complete Troubleshooting Guide

How to Fix AWS S3 Access Denied Error: A Complete Troubleshooting Guide

If you’ve ever deployed an application that interacts with Amazon S3, you’ve almost certainly run into the dreaded AccessDenied error. It’s the kind of error that can stop production dead in its tracks — and despite its vague wording, the root cause can hide in a surprising number of places.

This guide walks you through how to fix aws s3 access denied error systematically, starting with the most common culprits and moving into edge cases that took me hours (sometimes days) to track down on production systems. Whether you’re working with the AWS CLI, an SDK like boto3, or a frontend presigned URL, you’ll find a path forward here.


Understanding the AWS S3 AccessDenied Error

Before fixing anything, it helps to understand what S3 is actually telling you. The canonical error looks something like this:

An error occurred (AccessDenied) when calling the GetObject operation: Access Denied

Or, from an HTTP perspective:

HTTP/2.0 403 Forbidden
<?xml version="1.0" encoding="UTF-8"?>
<Error>
  <Code>AccessDenied</Code>
  <Message>Access Denied</Message>
  <RequestId>...</RequestId>
</Error>

A 403 Forbidden from S3 means the request was syntactically valid — AWS recognized your credentials, parsed your signature, and understood what you wanted to do — but something in your permission chain rejected the action. That chain has multiple links:

  1. The IAM identity making the request
  2. The IAM policy attached to that identity
  3. The bucket policy on the target bucket
  4. The object ACL (in legacy configurations)
  5. The encryption setup (especially KMS)
  6. Network-layer restrictions (VPC endpoints)
  7. Block Public Access settings

When any one of these says “no,” you get AccessDenied. Let’s work through each.


Most Common Causes (And How to Fix Them)

1. Missing or Incorrect IAM Permissions

This is the #1 cause I see in the wild, especially for developers new to AWS or teams using Terraform-managed IAM roles. Your IAM principal simply doesn’t have the specific S3 action it needs.

A typical mistake is granting s3:GetObject when the code is actually calling s3:PutObject, or using a bucket-level permission (s3:ListBucket) when an object-level action is required.

Here’s the diagnostic starting point — verify who you are and what policies you have:

aws sts get-caller-identity
aws iam list-attached-role-policies --role-name MyEC2Role
aws iam list-role-policies --role-name MyEC2Role

For an inline policy, retrieve the actual document:

aws iam get-role-policy \
  --role-name MyEC2Role \
  --policy-name S3Access

A properly scoped IAM policy for reading and writing objects in a specific bucket looks like this:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::my-bucket/*"
    },
    {
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::my-bucket"
    }
  ]
}

Note the two separate statementsListBucket targets the bucket ARN (no trailing /*), while object-level actions target the bucket’s contents (with /*). Mixing these up is one of the most common mistakes.

Quick test: Use the IAM Policy Simulator in the AWS Console to evaluate whether your role can perform the action against the specific ARN. It’s faster than deploying and testing.

2. Explicit Deny in the Bucket Policy

A bucket policy can override an IAM Allow with an explicit Deny. In AWS, an explicit Deny always wins — even if another policy allows the action.

Here’s a real-world bucket policy snippet that bit me once:

{
  "Effect": "Deny",
  "Principal": "*",
  "Action": "s3:*",
  "Resource": [
    "arn:aws:s3:::secure-bucket",
    "arn:aws:s3:::secure-bucket/*"
  ],
  "Condition": {
    "StringNotEquals": {
      "aws:SourceVpce": "vpce-0abc123def456"
    }
  }
}

This policy denies all S3 access unless the request comes from a specific VPC endpoint. A developer working from their laptop with valid IAM credentials will still get AccessDenied.

To inspect a bucket policy:

aws s3api get-bucket-policy --bucket my-bucket

If the JSON is too dense to parse visually, pipe it through jq:

aws s3api get-bucket-policy --bucket my-bucket \
  --query 'Policy' --output text | jq '.Statement[] | select(.Effect=="Deny")'

3. KMS Encryption Permission Mismatch

This one is sneaky. If your bucket uses AWS KMS-managed encryption (SSE-KMS), then reading or writing objects requires two layers of permission:

  1. S3 permissions (as above)
  2. KMS permissions to use the specific key

A perfectly valid S3 policy will still return AccessDenied if the caller can’t kms:Decrypt the key that encrypted the object.

Here’s how to check what’s encrypting an object:

aws s3api head-object --bucket my-bucket --key path/to/file.txt

Look for ServerSideEncryption: aws:kms and a SSEKMSKeyId value.

The fix is to grant KMS permissions on the IAM principal:

{
  "Effect": "Allow",
  "Action": [
    "kms:Decrypt",
    "kms:GenerateDataKey"
  ],
  "Resource": "arn:aws:kms:us-east-1:123456789012:key/abc123-..."
}

Cross-account KMS access is particularly painful — both the key policy AND the caller’s IAM policy must allow the action. There’s no shortcut here.

4. S3 Block Public Access Interfering

S3 has four “Block Public Access” settings that override everything else. If you’re trying to set up a public bucket for static hosting or CDN distribution, these will silently block you with an opaque AccessDenied or AccessControlListNotSupported error.

aws s3api get-public-access-block --bucket my-bucket

The output:

{
  "PublicAccessBlockConfiguration": {
    "BlockPublicAcls": true,
    "IgnorePublicAcls": true,
    "BlockPublicPolicy": true,
    "RestrictPublicBuckets": true
    }
}

To disable (only when you genuinely need public access — like for static website hosting behind CloudFront):

aws s3api put-public-access-block --bucket my-bucket \
  --public-access-block-configuration \
  BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false

I recommend keeping Block Public Access ON at the account level and only disabling at the bucket level when truly required — and pairing that bucket with CloudFront, OAC (Origin Access Control), and a strict bucket policy.

5. Object Ownership and ACL Conflicts

In April 2023, AWS changed the default for new S3 buckets to Object Ownership: Bucket Owner Enforced, which disables ACLs entirely. This is good for security but trips up workflows that relied on ACLs.

If you see this error:

AccessControlListNotSupported: The bucket does not allow ACLs

…then you’re hitting this setting. The fix is either to rewrite your permission model to use bucket policies, or to disable Bucket Owner Enforced (not recommended for new deployments):

aws s3api put-bucket-ownership-controls --bucket my-bucket \
  --ownership-controls Rules=[{ObjectOwnership=ObjectWriter}]

A more robust approach: keep Bucket Owner Enforced, and grant cross-account access via a bucket policy that explicitly names the writing account.


Edge Cases That Drive Developers Crazy

6. The “Object Doesn’t Exist” 403

This one is genuinely confusing. If you call GetObject on a key that doesn’t exist, and you don’t have s3:ListBucket permission, S3 returns AccessDenied instead of NoSuchKey. This is a deliberate information-disclosure protection — S3 won’t tell you “the object isn’t there” if you’re not allowed to know what’s in the bucket.

So before assuming it’s a permission issue, double-check that the object exists using credentials that have ListBucket:

aws s3 ls s3://my-bucket/path/to/file.txt
aws s3api head-object --bucket my-bucket --key path/to/file.txt

I once spent three hours debugging “permissions” on a Lambda function that was simply constructing the wrong key path. Adding ListBucket immediately revealed NoSuchKey.

7. VPC Endpoint Policies

If your application runs in a VPC with a VPC Gateway Endpoint for S3, that endpoint has its own policy. A misconfigured endpoint policy will block all S3 traffic from your VPC, even with perfect IAM.

aws ec2 describe-vpc-endpoints \
  --filters Name=service-name,Values=com.amazonaws.us-east-1.s3 \
  --query 'VpcEndpoints[0].PolicyDocument' --output text | jq .

A restrictive endpoint policy might look like:

{
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": "arn:aws:s3:::company-approved-*/*"
    }
  ]
}

This blocks access to any bucket not matching the prefix. Fix by broadening the resource pattern or adding explicit allow statements for the buckets you need.

8. Presigned URL Issues

Presigned URLs are a common source of “works for me but not for the customer” reports. They fail with AccessDenied for several reasons:

  • Expiration reached — The signature is only valid for the configured window.
  • Different credentials — The URL was signed by one role, but the SDK is using another to verify.
  • KMS-encrypted objects — Presigned URLs for SSE-KMS objects still require the requester to have KMS permissions, which defeats much of the point of presigning.
  • Region mismatch — The presigned URL pins a region. A client in another region using the same URL will sometimes fail.

For presigning with boto3, keep it simple and consistent:

import boto3
from botocore.client import Config

s3 = boto3.client(
    's3',
    region_name='us-east-1',
    config=Config(signature_version='s3v4')
)

url = s3.generate_presigned_url(
    'get_object',
    Params={'Bucket': 'my-bucket', 'Key': 'file.pdf'},
    ExpiresIn=3600
)

If you need presigned URLs for KMS-encrypted objects and the consumer is unauthenticated, consider copying the object to a temporary bucket with SSE-S3 encryption first.

9. Cross-Account Access Requires Both Sides

A classic gotcha: Account A wants to read from a bucket in Account B. You can’t just add an IAM policy in Account A — that’s necessary but not sufficient. You also need a bucket policy in Account B that explicitly trusts Account A.

Account A IAM policy:

{
  "Effect": "Allow",
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::account-b-bucket/*"
}

Account B bucket policy:

{
  "Effect": "Allow",
  "Principal": {
    "AWS": "arn:aws:iam::ACCOUNT_A_ID:role/MyRole"
  },
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::account-b-bucket/*"
}

Both are required. Either alone results in AccessDenied.

10. STS Session Tokens and SigV4 Issues

Temporary credentials from STS (used by Lambda, ECS task roles, and assumed roles) include a session token. If you’re passing credentials to a non-AWS SDK or constructing HTTP requests manually, forgetting the session token produces AccessDenied, not a clearer error.

Always pass all three values when working with temporary credentials:

s3 = boto3.client(
    's3',
    aws_access_key_id=os.environ['AWS_ACCESS_KEY_ID'],
    aws_secret_access_key=os.environ['AWS_SECRET_ACCESS_KEY'],
    aws_session_token=os.environ['AWS_SESSION_TOKEN'],
    region_name='us-east-1'
)

Also pay attention to region and service name in SigV4 signing. Custom signing code that uses s3 when the request goes to a different service, or vice versa, will fail with AccessDenied.


A Diagnostic Workflow for S3 AccessDenied

When an error hits, work through this checklist in order. It saves time compared to random changes:

  1. Confirm the calleraws sts get-caller-identity.
  2. Check the exact ARN — Bucket name, key path, and region. Watch for trailing slashes and leading /.
  3. Test with the AWS CLI — If the CLI works but your code doesn’t, the bug is in your code (credentials, region, endpoint).
  4. Inspect IAM policies — Inline and attached.
  5. Inspect the bucket policy — Look for explicit Deny statements.
  6. Check object encryption — SSE-KMS requires additional KMS permissions.
  7. Check Block Public Access — Account-level and bucket-level.
  8. Verify the object exists — Don’t skip this. It’s faster than you think to be wrong.
  9. Inspect VPC endpoint policies — If running inside a VPC.
  10. Try the IAM Policy Simulator — Especially useful for narrowing down which policy is the problem.

Here’s a quick diagnostic script I keep handy for object-level issues:

#!/usr/bin/env bash
set -euo pipefail

BUCKET=$1
KEY=$2

echo "=== Identity ==="
aws sts get-caller-identity

echo "=== Object metadata ==="
aws s3api head-object --bucket "$BUCKET" --key "$KEY" 2>&1 || true

echo "=== Bucket policy summary ==="
aws s3api get-bucket-policy --bucket "$BUCKET" --query 'Policy' --output text 2>&1 | jq '.Statement[] | {Effect, Principal, Action, Resource}' 2>/dev/null || echo "No bucket policy"

echo "=== Public Access Block ==="
aws s3api get-public-access-block --bucket "$BUCKET" 2>&1 || true

Prevention

Leave a Reply

Your email address will not be published. Required fields are marked *