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
Denyfor your IAM principal or IP range - Your IAM role/user lacks
s3:GetObjectors3:HeadObjectpermission - 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.comevents witherrorCode: 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-policybefore 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.

