CloudFront + S3 Static Website: Why You're Getting 403 and How to Fix It with OAC

You've deployed a static website to S3, put CloudFront in front of it, and now every request returns a cryptic 403 Forbidden. This is one of the most common — and most misunderstood — misconfigurations in AWS, rooted in a fundamental distinction between two different S3 origin types.

TL;DR

ApproachOrigin TypeBucket Public?OAC Supported?Recommended?
S3 Website EndpointCustom Origin (HTTP)✅ Must be public❌ No⚠️ Legacy only
S3 REST API EndpointS3 Origin (native)❌ Keep private✅ Yes✅ Recommended

The Root Cause: Two Completely Different S3 Endpoints

S3 exposes two distinct endpoint types, and CloudFront treats them very differently. Confusing them is the #1 source of 403 errors.

1. S3 Website Endpoint (Static Website Hosting)

When you enable Static Website Hosting in the S3 console, AWS activates a separate HTTP endpoint in the format:

http://<bucket-name>.s3-website-<region>.amazonaws.com

This endpoint behaves like a web server — it handles index.html routing, custom error pages, and redirects. However, it is HTTP-only and has no concept of AWS IAM authentication. CloudFront treats it as a Custom Origin. Because IAM-based access control doesn't apply here, the bucket's Block Public Access must be disabled and a public bucket policy must be attached. If either is missing, S3 returns 403.

2. S3 REST API Endpoint (Native S3 Origin)

This is the standard S3 endpoint format:

https://<bucket-name>.s3.<region>.amazonaws.com

CloudFront recognizes this as a native S3 Origin and can authenticate to it using Origin Access Control (OAC) — a signed request mechanism. The bucket stays completely private; only CloudFront can read from it via an IAM-based bucket policy.

Analogy: Think of the S3 Website Endpoint like a public library reading room — anyone can walk in. The S3 REST Endpoint with OAC is like a private archive — only a credentialed librarian (CloudFront) has a key, and the public never touches the shelves directly.

Why the 403 Happens: Decision Tree

flowchart TD Start(["CloudFront Request to S3"]) --> Q1{"Which S3 endpoint type is configured?"} Q1 -->|"Website Endpoint (s3-website-region)"| Q2{"Block Public Access enabled?"} Q1 -->|"REST Endpoint (s3.region)"| Q3{"OAC configured + bucket policy?"} Q2 -->|"Yes"| E1["❌ 403 Forbidden S3 rejects anonymous requests — no IAM here"] Q2 -->|"No"| Q4{"Public bucket policy exists?"} Q4 -->|"No"| E2["❌ 403 Forbidden No explicit Allow for s3:GetObject"] Q4 -->|"Yes"| S1["✅ 200 OK Bucket is public (legacy approach)"] Q3 -->|"No"| E3["❌ 403 Forbidden Unsigned request to private bucket"] Q3 -->|"Yes"| Q5{"Bucket policy SourceArn matches distribution?"} Q5 -->|"No"| E4["❌ 403 Forbidden Condition mismatch in bucket policy"] Q5 -->|"Yes"| S2["✅ 200 OK OAC signed request authorized — secure!"] style E1 fill:#ffcccc,stroke:#cc0000 style E2 fill:#ffcccc,stroke:#cc0000 style E3 fill:#ffcccc,stroke:#cc0000 style E4 fill:#ffcccc,stroke:#cc0000 style S1 fill:#fff3cc,stroke:#cc8800 style S2 fill:#ccffcc,stroke:#006600
  1. Website Endpoint + Block Public Access ON: S3 rejects all anonymous requests. CloudFront has no IAM credentials to use here, so every request returns 403.
  2. Website Endpoint + No Public Bucket Policy: Even with Block Public Access disabled, without an explicit s3:GetObject allow for *, S3 denies access.
  3. REST Endpoint + No OAC + Private Bucket: CloudFront forwards unsigned requests; S3 sees an anonymous caller with no permissions — 403.
  4. REST Endpoint + OAC configured correctly: CloudFront signs every request with SigV4; the bucket policy grants s3:GetObject to the specific CloudFront distribution — success.

The Recommended Architecture: OAC with Private S3 Bucket

sequenceDiagram autonumber participant User as User / Browser participant CF as CloudFront Distribution participant Cache as CloudFront Cache participant OAC as OAC (SigV4 Signer) participant S3 as S3 Bucket (Private) User->>CF: HTTPS GET /index.html CF->>Cache: Check cache alt Cache Hit Cache-->>CF: Cached object CF-->>User: 200 OK (from cache) else Cache Miss CF->>OAC: Forward request for signing OAC->>S3: Signed GET /index.html (SigV4 + SourceArn condition) S3->>S3: Evaluate bucket policy (Principal=cloudfront.amazonaws.com, SourceArn=distribution) S3-->>OAC: 200 OK + object bytes OAC-->>CF: Response CF->>Cache: Store in cache CF-->>User: 200 OK end Note over User,S3: Direct S3 URL access by other callers -> 403 Denied
  1. User sends an HTTPS request to the CloudFront distribution domain.
  2. CloudFront checks its cache. On a miss, it forwards the request to the S3 REST endpoint, signing it with SigV4 using OAC.
  3. S3 Bucket Policy evaluates the request: the principal is the CloudFront service principal (cloudfront.amazonaws.com) and the source distribution ARN matches — access is granted.
  4. S3 returns the object to CloudFront, which caches and serves it to the user.
  5. Direct S3 URL access by any other caller is denied — the bucket is fully private.

Step-by-Step Fix: Migrate to OAC

Step 1: Disable Static Website Hosting (or use a separate bucket)

If you only need CloudFront to serve files (no server-side redirects or custom error pages from S3), disable Static Website Hosting. Your CloudFront distribution will handle error pages natively.

Step 2: Ensure Block Public Access is ENABLED

In the S3 console, navigate to your bucket → PermissionsBlock Public Access → enable all four settings. This is the secure baseline.

Step 3: Create an OAC in CloudFront

🔽 AWS CLI: Create Origin Access Control
aws cloudfront create-origin-access-control \
  --origin-access-control-config '{
    "Name": "my-s3-oac",
    "Description": "OAC for private S3 bucket",
    "SigningProtocol": "sigv4",
    "SigningBehavior": "always",
    "OriginAccessControlOriginType": "s3"
  }'

Note the Id returned — you'll attach it to your CloudFront origin.

Step 4: Update Your CloudFront Distribution Origin

Set the origin domain to the S3 REST endpoint (not the website endpoint) and attach the OAC ID.

🔽 CloudFormation: CloudFront Distribution with OAC
AWSTemplateFormatVersion: '2010-09-09'
Resources:
  MyOAC:
    Type: AWS::CloudFront::OriginAccessControl
    Properties:
      OriginAccessControlConfig:
        Name: my-s3-oac
        SigningProtocol: sigv4
        SigningBehavior: always
        OriginAccessControlOriginType: s3

  MyDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Origins:
          - Id: S3Origin
            DomainName: !Sub "${MyBucket}.s3.${AWS::Region}.amazonaws.com"
            S3OriginConfig:
              OriginAccessIdentity: ""
            OriginAccessControlId: !GetAtt MyOAC.Id
        DefaultCacheBehavior:
          TargetOriginId: S3Origin
          ViewerProtocolPolicy: redirect-to-https
          CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
        DefaultRootObject: index.html
        Enabled: true

Step 5: Attach the Bucket Policy

Grant CloudFront's service principal access, scoped to your specific distribution. Replace DISTRIBUTION_ID and BUCKET_NAME accordingly.

🔽 S3 Bucket Policy: OAC Least-Privilege Grant
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontOAC",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::BUCKET_NAME/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/DISTRIBUTION_ID"
        }
      }
    }
  ]
}

Key security note: The AWS:SourceArn condition pins access to your specific distribution. Without it, any CloudFront distribution in any AWS account could potentially read your bucket if they guessed the name.

What If You Must Keep the Website Endpoint? (Legacy Path)

Some features — like S3-native redirect rules or routing rules — only work on the website endpoint. In that case, OAC is not an option. Your only path is a public bucket policy. Mitigate risk by:

  • Enabling CloudFront geo-restriction to limit exposure.
  • Using AWS WAF on the CloudFront distribution to filter malicious traffic.
  • Accepting that the S3 bucket URL itself is publicly accessible — consider whether that's acceptable for your threat model.

Common Pitfalls Checklist

SymptomLikely CauseFix
403 on all requestsWebsite endpoint used as S3 origin with OACSwitch to REST endpoint or remove OAC
403 on all requestsBlock Public Access ON + no OACAdd OAC + bucket policy
403 on all requestsBucket policy missing or wrong ARNVerify AWS:SourceArn matches distribution ARN
403 only on subpathsMissing DefaultRootObject or no index.html in subfolderUse CloudFront Functions for subfolder index routing
200 from CloudFront URL, 403 from S3 URL✅ Working as intended with OACNo action needed

Glossary

TermDefinition
OAC (Origin Access Control)A CloudFront feature that signs requests to S3 using SigV4, replacing the older OAI mechanism. Allows private S3 buckets to serve content exclusively through CloudFront.
S3 Website EndpointAn HTTP-only endpoint activated by enabling Static Website Hosting on an S3 bucket. Treated as a Custom Origin by CloudFront; requires public bucket access.
S3 REST EndpointThe standard S3 HTTPS API endpoint. Supports IAM-based authentication and is compatible with OAC.
Block Public AccessAn S3 account/bucket-level setting that overrides any public ACLs or bucket policies, preventing anonymous access regardless of other configurations.
SigV4 (Signature Version 4)AWS's request signing protocol. OAC uses SigV4 to cryptographically sign CloudFront's requests to S3, proving the request originates from an authorized distribution.

Next Steps

Related Posts

Comments