How to fix a CloudFront 403 Access Denied error from an S3 bucket

Updated · 5 min read

Your site is an S3 bucket behind CloudFront, and visitors get a 403. Before changing anything, find out who said no:

curl -i https://d111111abcdef8.cloudfront.net/index.html
  • An XML body with <Code>AccessDenied</Code>, and server: AmazonS3 in the headers: CloudFront reached your bucket and S3 refused. That's most cases, and sections 1 to 5 cover them.
  • An HTML page saying "The request could not be satisfied", with server: CloudFront: CloudFront refused before asking the bucket. Skip to the last section.

1. CloudFront isn't allowed to read the bucket

The recommended setup keeps the bucket private and lets only your distribution read it, using origin access control (OAC). It takes two parts, and a 403 usually means one is missing.

The origin has to use OAC. In CloudFront → Distributions, open your distribution, go to Origins, select the S3 origin and click Edit. Under Origin access, choose Origin access control settings (recommended) and pick a control setting (or create one with the default, Sign requests). Save.

The bucket policy has to let that distribution in. In S3 → the bucket → Permissions → Bucket policy → Edit, add this, with your bucket name, account ID and distribution ID:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipalReadOnly",
      "Effect": "Allow",
      "Principal": { "Service": "cloudfront.amazonaws.com" },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-site-bucket/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/E1ABCDEFGHIJKL"
        }
      }
    }
  ]
}

The usual slips: the distribution ID belongs to an older distribution you've since replaced, or Resource is missing the /*.

To check both from the terminal:

aws cloudfront get-distribution-config --id E1ABCDEFGHIJKL \
  --query 'DistributionConfig.{root: DefaultRootObject, origins: Origins.Items[].[DomainName, OriginAccessControlId]}'

aws s3api get-bucket-policy --bucket my-site-bucket --query Policy --output text

An empty OriginAccessControlId with a private bucket means a 403 on every request. Older distributions may use an origin access identity (OAI) instead, with a principal like arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E2EXAMPLE. It still works, but it can't read KMS-encrypted files or buckets in newer regions, and AWS recommends moving to OAC.

If the origin's domain looks like my-site-bucket.s3-website-us-east-1.amazonaws.com, it's S3's website endpoint, which can't use OAC or OAI and only serves buckets that are public. Either make the bucket public, or change the origin to the bucket itself (my-site-bucket.s3.us-east-1.amazonaws.com) and use OAC.

2. Files encrypted with a KMS key

If the bucket encrypts files with a KMS key you manage (SSE-KMS), CloudFront needs to use that key too. Add a statement to the key policy that allows the service principal cloudfront.amazonaws.com to call kms:Decrypt, with the same AWS:SourceArn condition. The default encryption, SSE-S3, needs nothing extra.

3. The home page: default root object

Without a default root object, a request for https://example.com/ asks S3 for the bucket itself rather than a file, and a private bucket answers 403. In your distribution's General tab, under Settings, click Edit and set Default root object to index.html. No leading slash: /index.html can itself cause a 403.

It only applies to the root. A request for /about/ doesn't become /about/index.html, even though S3's own website hosting does that. If your site has folder-style URLs, as most static site generators produce, add a CloudFront Function on viewer requests that appends index.html. AWS's CloudFront docs have one ready to copy, titled "Add index.html to request URLs without a file name".

4. The file doesn't exist

With OAC, CloudFront has s3:GetObject but not s3:ListBucket, and without list permission S3 answers a request for a missing file with 403, not 404. So a typo, the wrong case (/About.html), a file your deploy didn't upload, or an Origin path pointing at the wrong folder all look like permission problems. Check the file is where the URL says:

aws s3 ls s3://my-site-bucket/about/

Single-page apps (React, Vue, Angular) hit this on every refresh: /dashboard exists only in your JavaScript, not as a file. On the distribution's Error pages tab, click Create custom error response for 403, and again for 404, with response page path /index.html and HTTP response code 200. Your app then handles the route. The trade-off: a genuinely missing file now returns your app's page too.

5. The Host header

If a path's origin request policy forwards every viewer header to S3 (the managed AllViewer policy), S3 receives your domain as the Host instead of the bucket's and refuses. Use AllViewerExceptHostHeader or CORS-S3Origin instead, or no origin request policy at all.

When CloudFront itself says no

  • Your domain isn't on the distribution. DNS sends www.example.com to CloudFront, but it's not in the distribution's Alternate domain names. Add it, with an ACM certificate in us-east-1 that covers it.
  • AWS WAF blocked the request: check the rules on the web ACL attached to the distribution.
  • Geographic restrictions block the visitor's country.
  • Restrict viewer access is on for that path, so requests need signed URLs or cookies.
  • One distribution in front of another: CloudFront refuses those chains.

After a fix, CloudFront can keep serving the cached 403 for a short while (10 seconds by default, longer if you've set an error caching TTL). Clearing the cache for the affected paths makes it ask again straight away.

Or in Cloud GUI

Cloud GUI's Websites page follows each of your domains through its CloudFront distribution to the origin it fetches from, its certificate and its Route 53 record. A distribution's page shows the things this guide checks, in words: for each S3 origin, whether it's a private bucket reached with origin access control, with the older origin access identity, or "reached without signing" (which only works if the bucket is public); whether a home page (default root object) is set; what each path passes along to its origin, so an AllViewer policy on an S3 origin stands out; whether the certificate covers each domain; and what share of the last 24 hours' requests got visitor errors (4xx) and server errors (5xx). On Pro, with editing turned on, Clear cache on the same page clears the cached errors once the cause is fixed.

See your whole AWS account in one calm view

Cloud GUI shows your files and functions from every region at once, in plain English, with what's failing up front. It connects with a read-only role you create and can delete any time. Free for one AWS account.

More guides

Free tool
IAM policy explainer

Paste an IAM policy and read it in plain English, with the risky parts flagged. It runs in your browser; nothing is sent anywhere.