Fix AWS S3 403 Forbidden: HeadObject Operation Blocked by Bucket Policy or ACL

intermediateโ˜๏ธ AWS2026-04-22| AWS S3, AWS CLI v2, Python boto3, any AWS SDK โ€” all regions

Error Message

An error occurred (403) when calling the HeadObject operation: Forbidden
#aws#s3#bucket-policy#acl#permissions#403

The Error

You fire off what looks like a routine S3 command and get this:

$ aws s3 cp s3://my-bucket/data/report.csv .
fatal error: An error occurred (403) when calling the HeadObject operation: Forbidden

Or from boto3:

botocore.exceptions.ClientError: An error occurred (403) when calling the HeadObject operation: Forbidden

The bucket exists. The object exists. You can see it in the console. Yet the SDK won't touch it.

Why This Happens

S3 access isn't a single gate โ€” it's four. IAM identity policy, bucket policy, ACL, and block public access settings all evaluate independently, and any one of them can veto your request. The tricky part: a missing Allow looks exactly the same as an explicit Deny from the SDK's perspective.

The usual suspects:

  • Bucket policy has an explicit Deny for your IAM principal or IP range
  • Your IAM role/user lacks s3:GetObject or s3:HeadObject permission
  • Object ACL is set to private and bucket owner enforcement is off
  • The bucket lives in a different AWS account and cross-account trust isn't wired up
  • S3 Block Public Access is on while the bucket policy tries to grant public access
  • Server-side encryption uses a KMS key your role can't decrypt

Step-by-Step Fix

Step 1 โ€” Confirm your identity

Before touching any policy, check which IAM principal is actually making the call:

aws sts get-caller-identity

Output:

{
    "UserId": "AROAXXXXXXXXXXXXXXXXX:session",
    "Account": "123456789012",
    "Arn": "arn:aws:iam::123456789012:role/my-app-role"
}

Save that ARN. You'll be matching it against policy statements in the next steps.

Step 2 โ€” Check the bucket policy

aws s3api get-bucket-policy --bucket my-bucket | python3 -m json.tool

Hunt for "Effect": "Deny" statements that cover your ARN. Pay attention to conditions like aws:SourceIp or aws:PrincipalOrgID โ€” these can block you silently even when the principal looks correct.

Found a Deny covering your role? Either remove it or add an explicit Allow above it:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowAppRole",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/my-app-role"
      },
      "Action": ["s3:GetObject", "s3:HeadObject"],
      "Resource": "arn:aws:s3:::my-bucket/*"
    }
  ]
}

Apply it:

aws s3api put-bucket-policy --bucket my-bucket --policy file://policy.json

Step 3 โ€” Check IAM permissions

The bucket policy isn't the whole story. For same-account access, either the bucket policy or the identity policy allowing the action is enough. Cross-account access is stricter โ€” both must explicitly allow it.

Run the IAM Policy Simulator to see exactly what's happening:

aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::123456789012:role/my-app-role \
  --action-names s3:GetObject s3:HeadObject \
  --resource-arns arn:aws:s3:::my-bucket/data/report.csv

Got "EvalDecision": "implicitDeny" or "explicitDeny"? The identity policy needs updating. Attach something like this:

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

Step 4 โ€” Check ACL (if applicable)

ACLs only matter when Object Ownership isn't set to Bucket owner enforced. When they're active, an object-level ACL can override everything else:

aws s3api get-object-acl --bucket my-bucket --key data/report.csv

Object uploaded from another account and the ACL excludes you? Reset it:

aws s3api put-object-acl \
  --bucket my-bucket \
  --key data/report.csv \
  --acl bucket-owner-full-control

Better yet, eliminate ACLs completely and stop fighting them:

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

Step 5 โ€” Check KMS encryption

Customer-managed KMS keys add a layer that's easy to overlook. Run a head-object to see if one is involved:

aws s3api head-object --bucket my-bucket --key data/report.csv

Response shows ServerSideEncryption: aws:kms with a SSEKMSKeyId? Add KMS permissions to the IAM policy:

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

Verify the Fix

Run a quick sanity check before calling it done:

# HeadObject โ€” metadata only, fast
aws s3api head-object --bucket my-bucket --key data/report.csv

# Actual download
aws s3 cp s3://my-bucket/data/report.csv /tmp/report.csv
echo $?  # 0 = success

From boto3:

import boto3
s3 = boto3.client('s3')
try:
    resp = s3.head_object(Bucket='my-bucket', Key='data/report.csv')
    print('OK:', resp['ContentLength'], 'bytes')
except Exception as e:
    print('Still failing:', e)

Cross-Account Scenario

Bucket in Account A, role in Account B? Both sides must explicitly allow the action โ€” there's no way around it. The bucket policy in Account A needs to name your external principal directly:

{
  "Principal": {
    "AWS": "arn:aws:iam::ACCOUNT_B_ID:role/cross-account-role"
  },
  "Action": ["s3:GetObject", "s3:HeadObject"],
  "Effect": "Allow",
  "Resource": "arn:aws:s3:::my-bucket/*"
}

Account B's identity policy also needs to allow s3:GetObject on the target bucket. Miss either side and you're back to 403.

Tips

  • CloudTrail tells you which policy fired. Filter for s3.amazonaws.com events with errorCode: AccessDenied. The event detail names the exact policy that caused the denial โ€” far faster than reading policies blind.
  • IAM Policy Simulator is your best friend here. Run aws iam simulate-principal-policy before and after any change to confirm the delta. Don't guess; simulate.
  • Turn on S3 server access logging for a permanent audit trail โ€” who accessed what, when, and from which IP.
  • Working with EC2 instance profiles that mount S3-backed filesystems via s3fs? The ToolCraft Unix Permissions Calculator is useful for cross-checking file-level permissions on the Linux side. Runs entirely in the browser โ€” nothing leaves your machine.
  • Drop ACLs for new buckets. ACLs are a legacy mechanism. Mixing them with bucket policies produces exactly this kind of 403 โ€” hard to trace, annoying to fix. Bucket owner enforced mode plus a clean bucket policy is simpler and easier to audit.

Related Error Notes