CloudFront + S3 Static Website 403 Errors: OAC vs Public Bucket Explained

You've wired CloudFront in front of an S3 static website and the browser returns a 403 — but the bucket policy looks right, the distribution is deployed, and the objects are definitely there. This is one of the most common CloudFront misconfiguration patterns, and the root cause almost always comes down to a fundamental architectural choice: are you using S3 as a REST API origin or as a static website endpoint? That distinction determines everything about how permissions work.

TL;DR: CloudFront + S3 403 Root Causes at a Glance

ScenarioOrigin TypeAuth MechanismCommon 403 Cause
OAC (recommended)S3 REST endpointSigV4 signed requests via OACMissing or incorrect bucket policy allowing OAC principal
Public bucketS3 website endpointNo auth — public read requiredBlock Public Access enabled, or bucket policy missing s3:GetObject for *
Legacy OAIS3 REST endpointOAI principal in bucket policyOAI not attached to distribution, or policy references wrong OAI ID

How CloudFront-to-S3 Access Actually Works

S3 exposes two distinct endpoint types for the same bucket, and CloudFront treats them differently. The REST API endpoint (bucket.s3.amazonaws.com) enforces IAM-based access control — every request must be authorized. The static website endpoint (bucket.s3-website-region.amazonaws.com) bypasses IAM entirely and relies on the bucket being publicly readable. When you configure CloudFront's origin, the endpoint you choose locks you into one of these two access models. Mixing them — for example, pointing CloudFront at the REST endpoint while relying on a public bucket policy — produces 403s that look like permission errors but are actually an architectural mismatch.

graph LR User["Browser / User"] CF["CloudFront Distribution"] OAC["OAC (SigV4 Signing)"] S3REST["S3 REST Endpoint bucket.s3.amazonaws.com"] S3WEB["S3 Website Endpoint bucket.s3-website-region.amazonaws.com"] POLICY_OAC["Bucket Policy cloudfront.amazonaws.com principal + AWS:SourceArn condition"] POLICY_PUB["Bucket Policy Principal: '*' s3:GetObject"] BPA_ON["Block Public Access ENABLED"] BPA_OFF["Block Public Access DISABLED"] OBJ["S3 Object"] ERR["403 AccessDenied"] User --> CF CF -->|"Recommended path"| OAC OAC -->|"Signed request"| S3REST S3REST --> POLICY_OAC POLICY_OAC --> BPA_ON BPA_ON --> OBJ CF -->|"Public bucket path"| S3WEB S3WEB --> POLICY_PUB POLICY_PUB --> BPA_OFF BPA_OFF --> OBJ CF -->|"Mismatch: REST endpoint + public policy only"| S3REST S3REST -->|"No signed request, no OAC policy"| ERR style ERR fill:#ff4444,color:#fff style OAC fill:#2196F3,color:#fff style OBJ fill:#4CAF50,color:#fff
  1. OAC path (left): CloudFront signs every origin request with SigV4. S3 validates the signature against the bucket policy, which must explicitly allow the CloudFront service principal. No public access required.
  2. Public website endpoint path (right): CloudFront forwards unsigned requests to the S3 website endpoint. S3 checks only whether the object is publicly readable. Block Public Access must be fully disabled and a public s3:GetObject bucket policy must exist.
  3. Mismatch (center, red): Pointing CloudFront at the REST endpoint while expecting public-bucket behavior — or vice versa — produces a 403 that neither side logs as an explicit auth failure.

Step 1: Identify Which Origin Endpoint CloudFront Is Using

Before touching any policy, confirm what origin URL is actually configured. The CloudFront console auto-populates the origin domain when you select a bucket, but it may have defaulted to the REST endpoint even if you intended to use the website endpoint. These look similar but behave completely differently — and this is where most engineers waste an hour.

aws cloudfront get-distribution-config \
  --id YOUR_DISTRIBUTION_ID \
  --query 'DistributionConfig.Origins.Items[*].{Domain:DomainName,OAC:S3OriginConfig}' \
  --output table

Check the DomainName field in the output:

  • your-bucket.s3.amazonaws.com or your-bucket.s3.us-east-1.amazonaws.com → REST endpoint. OAC or OAI is required.
  • your-bucket.s3-website-us-east-1.amazonaws.com → Static website endpoint. Public read is required; OAC cannot be used here.

Once you know which endpoint is in use, the diagnostic path splits cleanly.

Path A: REST Endpoint — Fix the OAC Configuration (Recommended)

OAC is the current AWS-recommended approach for restricting S3 access to CloudFront. It replaces the older Origin Access Identity (OAI) mechanism. With OAC, CloudFront signs origin requests using SigV4, and S3 validates those signatures against a bucket policy that grants access to the CloudFront service principal. If either side of this handshake is misconfigured, S3 returns 403.

Step 2: Verify an OAC Exists and Is Attached to the Distribution

An OAC that exists but isn't attached to the correct origin does nothing. Confirm both the OAC exists and is wired to the right origin in your distribution config.

# List all OACs in your account
aws cloudfront list-origin-access-controls \
  --query 'OriginAccessControlList.Items[*].{Id:Id,Name:Name,SigningBehavior:SigningBehavior}' \
  --output table

# Confirm the OAC is attached to your distribution's S3 origin
aws cloudfront get-distribution-config \
  --id YOUR_DISTRIBUTION_ID \
  --query 'DistributionConfig.Origins.Items[*].{Domain:DomainName,OACId:OriginAccessControlId}' \
  --output table

If OriginAccessControlId is empty or null, the origin is making unsigned requests to S3 — which S3 will reject with 403 unless the bucket is public.

Step 3: Create and Attach an OAC If Missing

# Create the OAC
aws cloudfront create-origin-access-control \
  --origin-access-control-config '{
    "Name": "my-s3-oac",
    "Description": "OAC for S3 static site",
    "SigningProtocol": "sigv4",
    "SigningBehavior": "always",
    "OriginAccessControlOriginType": "s3"
  }'

Note the Id returned. Then update your distribution to attach it — this requires fetching the current config, modifying the origin's OriginAccessControlId, and calling update-distribution with the current ETag. The update triggers a deployment; allow a few minutes for propagation.

Step 4: Verify the Bucket Policy Grants the CloudFront Service Principal

This is the step most engineers get wrong. The bucket policy must reference the CloudFront distribution ARN — not just the service principal alone. Without the aws:SourceArn condition, the policy grants access to all CloudFront distributions in all accounts, which is a security risk and not what AWS recommends.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontOAC",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::your-bucket-name/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/YOUR_DISTRIBUTION_ID"
        }
      }
    }
  ]
}

Apply this policy:

aws s3api put-bucket-policy \
  --bucket your-bucket-name \
  --policy file://bucket-policy.json

Step 5: Confirm Block Public Access Does Not Conflict

With OAC, Block Public Access should remain enabled — that's the point. But verify that no legacy public-read ACL or conflicting bucket policy statement is present that might be causing S3 to evaluate a Deny before reaching the Allow.

aws s3api get-bucket-policy-status \
  --bucket your-bucket-name

aws s3api get-public-access-block \
  --bucket your-bucket-name

For OAC, you want IsPublic: false and all Block Public Access settings enabled. If IsPublic: true appears alongside an OAC setup, a conflicting public ACL or policy statement is present — remove it.

Think of OAC like a VIP entrance with a signed guest list. CloudFront signs the request (the signature is the invite), and S3 checks the guest list (the bucket policy). If the bouncer's list doesn't name your specific event (the distribution ARN condition), anyone with a CloudFront badge gets in — not what you want.

Path B: Static Website Endpoint — Fix Public Access Configuration

If you're using the S3 website endpoint, OAC is not applicable — the website endpoint doesn't support SigV4 authentication. Public read is the only access model. This path is simpler but requires the bucket to be genuinely public, which means Block Public Access must be disabled and a bucket policy must explicitly allow s3:GetObject for all principals.

Step 6: Disable Block Public Access

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

Step 7: Apply a Public Read Bucket Policy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::your-bucket-name/*"
    }
  ]
}
aws s3api put-bucket-policy \
  --bucket your-bucket-name \
  --policy file://public-bucket-policy.json

Note that making a bucket public exposes all objects to the internet directly via the S3 URL, not just through CloudFront. If restricting access to CloudFront only is a requirement, the REST endpoint with OAC is the correct architecture — not the website endpoint with a public bucket.

The Misdiagnosis That Costs an Hour

Here's the failure pattern that shows up repeatedly in production: an engineer sets up CloudFront, selects the bucket from the console dropdown, and the console auto-populates the REST endpoint (bucket.s3.amazonaws.com). They then enable static website hosting on the bucket and add a public bucket policy — expecting the public policy to satisfy CloudFront's origin requests. It doesn't. The REST endpoint requires signed requests or an explicit IAM-based bucket policy. The public policy only satisfies anonymous HTTP requests to the website endpoint.

The 403 that comes back has no CloudFront error detail — it's a raw S3 403, which CloudFront surfaces as-is. CloudFront access logs show 403 with an empty x-edge-result-type of Error, and S3 server access logs (if enabled) show AccessDenied. Engineers see the public bucket policy, assume it's correct, and start chasing CloudFront cache behaviors or geo-restriction settings — neither of which is the problem.

The fix is a single architectural decision: pick one endpoint type and configure the matching access model end-to-end.

flowchart TD START(["CloudFront returning 403"]) CHECK_ORIGIN["Step 1: Check origin DomainName in distribution config"] IS_REST{"Endpoint type?"} REST_PATH["REST endpoint bucket.s3.amazonaws.com"] WEB_PATH["Website endpoint bucket.s3-website-region.amazonaws.com"] CHECK_OAC["Step 2: Is OAC attached to this origin?"] OAC_MISSING["Step 3: Create OAC and attach to distribution"] CHECK_POLICY["Step 4: Does bucket policy allow cloudfront.amazonaws.com with AWS:SourceArn condition?"] FIX_POLICY["Fix bucket policy add service principal + condition"] CHECK_BPA_REST["Step 5: Verify Block Public Access is ENABLED (correct for OAC)"] CHECK_BPA_WEB["Step 6: Is Block Public Access fully DISABLED?"] DISABLE_BPA["Disable all Block Public Access settings on bucket"] CHECK_PUB_POLICY["Step 7: Does bucket policy allow s3:GetObject for Principal '*'?"] ADD_PUB_POLICY["Add public read bucket policy"] RESOLVED(["403 Resolved"]) START --> CHECK_ORIGIN CHECK_ORIGIN --> IS_REST IS_REST -->|"REST"| REST_PATH IS_REST -->|"Website"| WEB_PATH REST_PATH --> CHECK_OAC CHECK_OAC -->|"No"| OAC_MISSING OAC_MISSING --> CHECK_POLICY CHECK_OAC -->|"Yes"| CHECK_POLICY CHECK_POLICY -->|"No"| FIX_POLICY FIX_POLICY --> CHECK_BPA_REST CHECK_POLICY -->|"Yes"| CHECK_BPA_REST CHECK_BPA_REST --> RESOLVED WEB_PATH --> CHECK_BPA_WEB CHECK_BPA_WEB -->|"No"| DISABLE_BPA DISABLE_BPA --> CHECK_PUB_POLICY CHECK_BPA_WEB -->|"Yes"| CHECK_PUB_POLICY CHECK_PUB_POLICY -->|"No"| ADD_PUB_POLICY ADD_PUB_POLICY --> RESOLVED CHECK_PUB_POLICY -->|"Yes"| RESOLVED style RESOLVED fill:#4CAF50,color:#fff style START fill:#ff4444,color:#fff
  1. Start by identifying the origin endpoint type from the distribution config.
  2. If REST endpoint: verify OAC attachment, then verify the bucket policy references the correct distribution ARN in the AWS:SourceArn condition.
  3. If website endpoint: verify Block Public Access is fully disabled and a public s3:GetObject policy exists.
  4. If the endpoint type is wrong for your intent, update the origin domain in the distribution and redeploy.

IAM Permissions Required to Execute These Steps

The engineer running these commands needs the following minimum permissions. Read/List actions on CloudFront distributions require "Resource": "*" as they do not support resource-level restrictions for those specific actions — verify current support in the AWS Service Authorization Reference.

🔽 Click to expand — IAM policy for CloudFront + S3 diagnosis
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CloudFrontRead",
      "Effect": "Allow",
      "Action": [
        "cloudfront:GetDistribution",
        "cloudfront:GetDistributionConfig",
        "cloudfront:ListOriginAccessControls",
        "cloudfront:CreateOriginAccessControl"
      ],
      "Resource": "*"
    },
    {
      "Sid": "S3BucketPolicy",
      "Effect": "Allow",
      "Action": [
        "s3:GetBucketPolicy",
        "s3:PutBucketPolicy",
        "s3:GetBucketPolicyStatus",
        "s3:GetPublicAccessBlock",
        "s3:PutPublicAccessBlock"
      ],
      "Resource": "arn:aws:s3:::your-bucket-name"
    }
  ]
}

Wrap-Up: CloudFront + S3 403 Resolution and Next Steps

The CloudFront S3 403 error almost always traces back to an endpoint-access model mismatch. The decision is straightforward: use the REST endpoint with OAC if you want to keep the bucket private (recommended), or use the website endpoint with a fully public bucket if you need S3 website-specific features like custom error documents and index document routing. Don't mix the two.

For new deployments, AWS recommends OAC over both OAI and public buckets. OAI is still functional but is considered legacy. The OAC approach keeps your bucket private, supports SSE-KMS encrypted objects, and scopes access to a specific distribution via the AWS:SourceArn condition.

Glossary

TermDefinition
OAC (Origin Access Control)Current AWS mechanism for authorizing CloudFront to access a private S3 bucket using SigV4-signed requests and a bucket policy with the CloudFront service principal.
OAI (Origin Access Identity)Legacy CloudFront mechanism for S3 access control, superseded by OAC. Still functional but not recommended for new deployments.
S3 REST EndpointThe standard S3 API endpoint (bucket.s3.amazonaws.com) that enforces IAM-based access control on every request.
S3 Website EndpointThe static website hosting endpoint (bucket.s3-website-region.amazonaws.com) that serves public content without IAM authentication.
Block Public AccessAccount- and bucket-level S3 settings that override bucket policies and ACLs to prevent public exposure of objects.

Related Posts

Comments

Popular posts from this blog

EC2 No Internet Access in Custom VPC: Fix Internet Gateway and Route Table

EC2 SSH Connection Timeout: Which Security Group Rules to Check

Difference Between IAM User and IAM Role: Which One Should Your EC2 Use?