Fixing S3 Access Point Policy Mismatch with IAM Role: Access Denied Root Cause & Resolution
Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 15 mins
TL;DR
- What broke: Your S3 Access Point policy and the IAM role policy are evaluated as a logical AND by AWS — if either denies or omits the action/principal, the request dies with
AccessDenied. The delegating bucket policy also must explicitly grant the Access Point authority to serve requests. - How to fix it: Align the Access Point resource policy principal to the exact IAM role ARN, scope the bucket policy to delegate via
s3:DataAccessPointArn, and ensure the IAM role's identity policy grantss3:*on the Access Point ARN — not the bucket ARN. - Call to action: Use the Client-Side Sandbox above to paste both policies — it will auto-diff the principal and resource ARN mismatches without sending your ARNs anywhere.
The Incident (What does the error mean?)
Raw error from CloudTrail or application logs:
An error occurred (AccessDenied) when calling the GetObject operation:
User: arn:aws:sts::123456789012:assumed-role/DataPipelineRole/session
is not authorized to perform: s3:GetObject
on resource: arn:aws:s3:us-east-1:123456789012:accesspoint/my-access-point/object/data/file.parquet
because no resource-based policy allows the s3:GetObject action
Immediate consequence: Every read/write from your data pipeline, Lambda, or ECS task against this Access Point returns 403. Glue jobs fail silently. Kinesis Firehose delivery streams start backing up. S3 Event Notifications stop. This is not a transient error — it will not self-heal.
AWS enforces a three-layer evaluation for Access Point requests:
- IAM identity policy on the calling principal must allow the action on the Access Point ARN.
- Access Point resource policy must allow the calling principal.
- Bucket policy must delegate authority to the Access Point (or be absent, which implicitly delegates).
All three must independently allow. One gap = full block.
The Attack Vector / Blast Radius
This misconfiguration cuts both ways:
Availability blast radius: Any service assuming DataPipelineRole — Glue, EMR, Lambda, ECS — is completely locked out of the data lake partition behind this Access Point. If this is a shared Access Point serving multiple prefixes, the outage is not scoped to one job — it's the entire Access Point.
Security blast radius (the worse direction): Engineers under pressure to restore pipeline SLAs will broaden policies to "Principal": "*" or "Resource": "arn:aws:s3:::my-bucket/*" (the bucket ARN, not the Access Point ARN) as a "temporary" fix. A bucket policy with Principal: * and no aws:SourceVpc or aws:PrincipalOrgID condition key turns your private data lake into a public endpoint. Access Points exist precisely to prevent this — misconfiguring them defeats the entire control plane.
Additionally: if the IAM role policy uses a wildcard resource (arn:aws:s3:::*) to "fix" the denial, any new Access Point or bucket created in the account is immediately accessible to that role — a privilege escalation vector if the role is ever compromised.
How to Fix It (The Solution)
Root Cause Checklist
Before touching policies, confirm the actual mismatch:
# Verify the Access Point ARN format — region is NOT optional
aws s3control get-access-point \
--account-id 123456789012 \
--name my-access-point
# Simulate the call — catches policy eval before runtime
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/DataPipelineRole \
--action-names s3:GetObject \
--resource-arns "arn:aws:s3:us-east-1:123456789012:accesspoint/my-access-point/object/data/file.parquet"
Basic Fix — Align All Three Policies
1. Access Point Resource Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
- "Principal": { "AWS": "arn:aws:iam::123456789012:root" },
+ "Principal": { "AWS": "arn:aws:iam::123456789012:role/DataPipelineRole" },
"Action": ["s3:GetObject", "s3:PutObject"],
- "Resource": "arn:aws:s3:::my-bucket/*"
+ "Resource": "arn:aws:s3:us-east-1:123456789012:accesspoint/my-access-point/object/*"
}
]
}
2. Bucket Policy — Delegation Block
{
"Version": "2012-10-17",
"Statement": [
+ {
+ "Effect": "Allow",
+ "Principal": { "AWS": "*" },
+ "Action": "s3:*",
+ "Resource": [
+ "arn:aws:s3:::my-bucket",
+ "arn:aws:s3:::my-bucket/*"
+ ],
+ "Condition": {
+ "StringEquals": {
+ "s3:DataAccessPointAccount": "123456789012"
+ }
+ }
+ }
]
}
3. IAM Role Identity Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
- "Resource": "arn:aws:s3:::my-bucket/*"
+ "Resource": "arn:aws:s3:us-east-1:123456789012:accesspoint/my-access-point/object/*"
}
]
}
Enterprise Best Practice — Least Privilege with Condition Keys
// Access Point Policy — scoped to VPC and org
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::123456789012:role/DataPipelineRole" },
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:us-east-1:123456789012:accesspoint/my-access-point/object/*",
+ "Condition": {
+ "StringEquals": {
+ "aws:PrincipalOrgID": "o-xxxxxxxxxx"
+ },
+ "StringLike": {
+ "s3:prefix": ["data/pipeline-a/*", "data/pipeline-b/*"]
+ },
+ "Bool": {
+ "aws:SecureTransport": "true"
+ }
+ }
}
]
}
Scope the Access Point to a VPC at creation time — this cannot be changed after creation:
aws s3control create-access-point \
--account-id 123456789012 \
--name my-access-point \
--bucket my-bucket \
--vpc-configuration VpcId=vpc-0abc123def456
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
Checkov — catch resource ARN drift before terraform apply:
# .checkov.yml
checks:
- CKV_AWS_300 # S3 Access Point policy allows public access
- CKV_AWS_301 # S3 bucket policy delegates to Access Point account
OPA/Conftest policy — enforce Access Point ARN format in Terraform plans:
package s3_access_point
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_access_point"
policy := json.unmarshal(resource.change.after.policy)
stmt := policy.Statement[_]
contains(stmt.Resource, "arn:aws:s3:::")
not contains(stmt.Resource, "accesspoint")
msg := sprintf(
"Access Point policy resource must use Access Point ARN, not bucket ARN. Got: %v",
[stmt.Resource]
)
}
GitHub Actions — block PRs with bucket ARNs in Access Point policies:
- name: Lint S3 Access Point Policies
run: |
conftest test ./terraform --policy ./policies/s3_access_point.rego
checkov -d ./terraform --check CKV_AWS_300,CKV_AWS_301 --hard-fail-on HIGH
Terraform — always output the Access Point ARN and validate it in downstream resources:
output "access_point_arn" {
value = aws_s3_access_point.main.arn
# Format: arn:aws:s3:REGION:ACCOUNT:accesspoint/NAME
# NOT: arn:aws:s3:::BUCKET
}
# Reference this output — never hardcode
resource "aws_iam_role_policy" "pipeline" {
policy = jsonencode({
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject"]
Resource = "${aws_s3_access_point.main.arn}/object/*"
}]
})
}