CloudFront 403 From an S3 Origin: Fix Access Denied With OAC

Logeshwaran.C

Most AccessDenied errors from a CloudFront-fronted S3 bucket are not really an S3 permissions problem. They're a missing sentence in the bucket policy: one that names the exact CloudFront distribution allowed to read the bucket. The fix almost never requires making anything public — the counterintuitive part is that the "safer," fully private setup is usually the one that's broken, because nobody told S3 which distribution to trust.

⚡ Quick Answer

Confirm your origin is the S3 REST endpoint (bucket-name.s3.amazonaws.com), not the website endpoint — the two need completely different fixes.

Add a bucket policy statement that allows cloudfront.amazonaws.com to run s3:GetObject, scoped to your distribution's ARN with a Condition block.

Attach an Origin Access Control (OAC) to the S3 origin in the CloudFront console: Origins tab → Edit → Origin access control settings.

Already have a bucket policy and still see AccessDenied? Jump straight to the hard cases. Setting up OAC for the first time? Start at Setting Up OAC From Scratch.

Wait! if you are a new reader here and don't know about AWS because you didn't had money to learn or from Non Tech background, don't feel bad! I had create one hub, start from here: Learn AWS its free, and all in plain english so it will be easier for u to understand.. once its done come back here, it will be more easier..! Now, let's go back to topic and we will start as usual with Jake.


Jake's shop sells refurbished phones, and the product pages — spec sheets, warranty PDFs, trade-in photos — live in a private S3 bucket behind a CloudFront distribution. Last Saturday a customer tried to open a warranty PDF mid-checkout, got a blank page with <Code>AccessDenied</Code> in raw XML, and walked out. Jake didn't touch anything. That's usually the tell: nobody "broke" it, because it was never fully wired up in the first place.

This post walks through the fix in the order that actually resolves it fastest: confirming which kind of S3 endpoint you're pointed at, writing the bucket policy CloudFront needs, setting up Origin Access Control from a blank slate, and then the deeper traps — SSE-KMS encryption, object ownership across accounts, a root object that was never defined, and the org-level policies nobody remembers exist. Every step names the exact console page and setting, not a vague "check your permissions."

What "AccessDenied" Actually Means Here

When CloudFront returns that error, the request made it all the way to your S3 bucket and S3 itself refused it. That's actually good news: it means DNS, TLS, and the distribution are all fine. The problem is entirely in who's allowed to read the object.

S3 has exactly two ways to let CloudFront in: making the objects public, or telling S3, through a bucket policy, to trust requests that arrive already signed by CloudFront on behalf of one specific distribution. The mechanism that does that signing is called an Origin Access Control, or OAC. A lot of older guides jump straight to "just make the bucket public" as the fast fix. It works, but it defeats the entire reason you put a private bucket behind CloudFront in the first place — the correct fix below almost never requires that step.

‍♂️ Jake's Reality Check

"I never touched the bucket policy. Why would it suddenly start denying access?"

It was probably never granting access in the first place. A private S3 bucket denies everyone by default, including your own CloudFront distribution, until you explicitly add a statement that names it. If the warranty PDFs worked before, either the bucket was public, or someone added the CloudFront statement once and it's since been overwritten by a console change or a Terraform apply that reset the policy.

Confirm You're Using the REST Endpoint, Not the Website Endpoint

Before touching any policy, check your distribution's origin domain name. This one setting decides which entire fix path applies to you, and mixing them up is the single most common reason people follow instructions correctly and still see the error.

Origin domain name looks like Endpoint type Can use OAC?
your-bucket.s3.amazonaws.com or your-bucket.s3.us-east-1.amazonaws.com S3 REST API endpoint Yes — this is what OAC and the bucket policy fix below apply to
your-bucket.s3-website-us-east-1.amazonaws.com (dash or dot format, by Region) S3 static website endpoint No — must be a custom origin; the objects must be publicly readable instead

Find this under the distribution's Origins tab in the CloudFront console. The rest of this post assumes the REST API endpoint, since that's what a "private S3 bucket" setup almost always means and what OAC is built for.

 What changed between OAI and OAC

  • Before: Origin Access Identity (OAI) was the only way to keep an S3 origin private, going back to 2015-era CloudFront.
  • Now: Origin Access Control (OAC), launched in August 2022, replaced it as the recommended method, adding support for every AWS Region — including opt-in Regions launched after December 2022 — plus SSE-KMS encryption and signed PUT/DELETE requests, none of which OAI ever supported.
  • What that means for you: unless you inherited an older distribution, set up OAC, not OAI. If you already have OAI, it still works, but AWS doesn't recommend building new distributions on it.

Jake's first instinct, once he heard OAI was the "old" way, was to rip out his existing setup that same afternoon just to be done with it. Ethan talked him out of it: "Don't delete anything until the new one's actually served a real request. I've watched people pull the ladder out from under themselves for the sake of a tidy config." The migration steps later in this post exist for exactly that reason — leave both in place until you've confirmed the new path actually works.

Grant CloudFront's OAC Access With a Bucket Policy

This is the step most tutorials gloss over, and it's the one that fixes the majority of cases. CloudFront has to be told, inside the S3 bucket policy, that requests carrying its signature are allowed to call s3:GetObject. Do this before you attach the OAC to the origin, not after.

On the bucket's Permissions tab, under Bucket policy, add a statement like this. Replace the account ID, distribution ID, and bucket name with your own:

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

Two details break this more often than anything else. First, the Resource ends in /* — without it, the policy grants permission on the bucket itself, not the objects inside it, and every request still comes back denied. Second, the AWS:SourceArn condition has to match your specific distribution's ARN exactly; copy it from the distribution's General tab rather than retyping it, since a single mistyped character in the distribution ID silently produces the same AccessDenied response with no hint about why.

✅ Why this is the one to use

Scoping the condition to AWS:SourceArn means only your distribution can use this permission — not every CloudFront distribution on AWS. Without that condition, any AWS customer's CloudFront distribution could theoretically be pointed at your bucket and read it, since the principal is the shared cloudfront.amazonaws.com service, not something unique to your account.

Setting Up OAC From Scratch

If you don't have an OAC attached yet, the bucket policy above only does half the job — CloudFront also has to actually be told to sign its requests to S3. Here's the full sequence in the console.

  1. In the CloudFront console, open the left-hand navigation and choose Origin access, then Create control setting.
  2. Give it a name, leave the signing behavior on Sign requests (recommended), and set Origin type to S3. Choose Create.
  3. Open your distribution, go to the Origins tab, select the S3 origin, and choose Edit.
  4. For Origin access, choose Origin access control settings (recommended), then pick the OAC you just created from the dropdown.
  5. Choose Save changes. The distribution redeploys to all edge locations and starts signing every request it sends to the bucket.

⚠️ What this actually breaks

The "Do not sign requests" signing behavior only works if the S3 bucket is publicly accessible. If you select it while the bucket is still private, CloudFront stops signing entirely and every request comes back as an S3 permission error — on a distribution that otherwise looks correctly configured. Leave signing on unless you have a specific reason to turn it off for every origin sharing that OAC.

If you'd rather script this than click through the console, CloudFront's create-origin-access-control CLI command and the AWS::CloudFront::OriginAccessControl CloudFormation resource type both take the same three settings: a name, OriginAccessControlOriginType: s3, and SigningBehavior: always. Attach the resulting ID to the origin's OriginAccessControlId field, whether you're calling UpdateDistribution on an existing distribution or CreateDistribution on a new one.

Block Public Access and Object Ownership Traps

Two settings live outside the bucket policy and can quietly override everything above. Neither shows up as an obvious error — both just look like the same AccessDenied response.

S3 Block Public Access can be applied at the account level as well as the bucket level, and account-level settings win even if the bucket itself looks fine. This normally isn't the culprit for an OAC setup, since OAC doesn't require the bucket to be public — but if someone previously tried the "just make it public" fix and Block Public Access was already on, the policy change silently did nothing, and it's worth ruling out before you keep debugging the bucket policy.

Object Ownership is the quieter trap. OAC requires the bucket's Object Ownership setting to be Bucket owner enforced — the default for any bucket created recently. If an older bucket still has ACLs enabled, or if objects were uploaded by a different AWS account than the one that owns the bucket, a bucket-policy grant like the one above won't cover those specific objects, because policy-based access only applies when the bucket owner also owns the object. Check this on the bucket's Permissions tab, under Object Ownership; switching an existing bucket to Bucket owner enforced also disables any ACLs on it going forward.

The SSE-KMS Special Case

If your objects are encrypted with an AWS KMS key rather than the default S3-managed encryption, the bucket policy fix above still applies — but it isn't enough on its own. CloudFront also needs permission to use the KMS key to decrypt the object before it can serve it.

Check whether this is your situation first: open an object's properties in the S3 console and look at the Encryption box, or run the CLI's head-object command and check whether ServerSideEncryption comes back as aws:kms. If it does, go to the key's policy in the KMS console and add a statement like this:

{
  "Sid": "AllowCloudFrontServicePrincipalSSE-KMS",
  "Effect": "Allow",
  "Principal": { "Service": ["cloudfront.amazonaws.com"] },
  "Action": ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey*"],
  "Resource": "*",
  "Condition": {
    "StringEquals": {
      "AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EDFDVBD6EXAMPLE"
    }
  }
}

This is exactly the case that trips up people who migrated straight from a legacy OAI setup: OAI never supported KMS-encrypted objects at all, so if that's what you're coming from, the AccessDenied error may not be a migration bug — it may be the first time this bucket's encryption was ever compatible with CloudFront reading it.

Migrating From the Legacy OAI to OAC

If your distribution already uses OAI and you want to move to OAC — for KMS support, or simply because it's the current recommendation — do it in an order that never leaves a gap where CloudFront can't reach the bucket at all.

  1. Update the bucket policy first, adding a statement for the new OAC's cloudfront.amazonaws.com principal alongside the existing OAI statement, not in place of it. Both should grant s3:GetObject during the transition.
  2. Create the OAC and attach it to the origin, following the steps in the section above.
  3. Wait for the distribution to finish deploying to all edge locations before touching anything else.
  4. Once you've confirmed the distribution is serving normally, go back to the bucket policy and remove the old OAI statement.

Skipping straight to step 2 — swapping the origin access setting before the bucket policy allows the new principal — is the most common way people turn a working distribution into a broken one mid-migration, because for a few minutes CloudFront is signing requests that the bucket policy doesn't yet recognize.

Root Object and Case-Sensitivity Gotchas

Not every AccessDenied is a permissions error at all. S3 returns the exact same response for a genuinely missing object as it does for one it isn't allowed to read — on purpose, so an attacker can't use the error message to figure out which files exist in a bucket they don't have access to.

Two ways this shows up as a false permissions problem:

Missing default root object. If a viewer requests the bare domain (/) and the distribution has no default root object configured, S3 doesn't treat the trailing slash as a valid object name and returns AccessDenied instead of serving index.html. Set the default root object on the distribution's General settings, and enter it as index.html, not /index.html — the leading slash is one of the more common typos in this exact field.

Case and path mismatches. S3 object keys are case-sensitive. A request for Logo.png against an object stored as logo.png comes back AccessDenied, not a helpful "did you mean." Use the CLI's head-object command against the exact key you expect, and if you need to see precisely what path CloudFront is requesting from S3, turn on server access logging.

⚠️ What this actually breaks

CloudFront caches the AccessDenied response from S3 for up to five minutes. If you just fixed the bucket policy and are still seeing the old error, that's expected — either wait it out or run an invalidation on the affected path so the next request goes back to the origin instead of serving the cached error.

Multi-Origin Setups: S3 and API Gateway on One Distribution

‍♂️ Jake's Reality Check

"My storefront's static pages come from S3, but the trade-in price calculator hits an API. Can both live behind the one CloudFront domain?"

Ethan's take is blunt about this: "Yes, but treat it as two separate origins with a routing rule between them — don't try to make one origin do both jobs." Add the S3 origin as the default (*) behavior for everything else, and add a second origin pointing at your API Gateway endpoint with a path pattern like /api/* routed to it as a non-default behavior.

The trap here isn't Access Denied, it's a silent 404: CloudFront can prepend to the path it sends to an origin (using the origin's Origin Path setting), but it has no built-in way to strip a prefix. If your API is actually listening at /hello but your CloudFront path pattern is /api/*, CloudFront forwards the full /api/hello path to API Gateway, which doesn't have anything registered there and returns its own not-found response. Set the Origin Path on the API Gateway origin so the prefix gets trimmed before the request leaves CloudFront, or design your API routes to include the same prefix your CloudFront behavior uses.

Each origin keeps its own access model: the S3 origin still needs the OAC and bucket policy from earlier in this post, and the API Gateway origin has its own separate authorization, entirely independent of anything covered here.

When None of This Works: The Hard Cases

If the bucket policy is correct, the OAC is attached and signing, the object exists at the right case-sensitive path, and it's still AccessDenied, work through these less common causes in order.

An explicit Deny statement elsewhere in the policy. A Deny always overrides an Allow, even one that looks perfectly correct. It's easy to miss a second statement further down the same bucket policy — for example, one restricting access to a specific VPC endpoint (aws:sourceVpce) that has nothing to do with CloudFront at all, left over from an earlier setup. Read the whole policy, not just the statement you added.

Requester Pays. If this is turned on for the bucket, anonymous requests need the request to carry a request-payer parameter or they're denied. Turn this off unless you specifically need it and know how to account for it.

Service Control Policies. If your account belongs to an AWS Organization, an SCP attached at the organization root, an organizational unit, or directly to your account can deny s3:GetObject regardless of what the bucket policy says. This has to be checked from the organization's management account, and it's easy to overlook because it doesn't appear anywhere in the S3 or CloudFront console for the account being denied. Ethan's blunt about this one, too: "If your bucket policy is objectively correct and it's still denied, stop re-reading S3 docs and go ask whoever runs your AWS Organization." That's an honest limit, not a hedge — if you're not the account that manages the Organization, you cannot check or remove an SCP yourself. It's a call only someone with that access can make.

Two distributions chained together. If one CloudFront distribution sits in front of another as part of the request path, CloudFront returns a 403. This is called out specifically as something to avoid rather than a bug to route around — restructure the setup so there's a single distribution in the chain.

Signed URLs or cookies with Restrict Viewer Access on. If the cache behavior has viewer restriction turned on but the request isn't using a signed URL or signed cookie, that's a different flavor of AccessDenied than everything covered above, and it needs to be diagnosed as a signing problem rather than a bucket-policy problem.

OAC vs. OAI vs. a Public Bucket, Side by Side

For anyone deciding which model to build on rather than fixing an existing one, here's how the three approaches actually compare.

Method SSE-KMS support Use it when
Origin Access Control (OAC) Yes, with a KMS key policy statement Any new setup, or an existing bucket you're securing today — this is the current recommendation
Origin Access Identity (OAI, legacy) No Only if you already have it working and have no reason to migrate yet
Fully public bucket N/A — content is unencrypted-access anyway Content you'd be fine with anyone finding directly at the S3 URL, bypassing CloudFront entirely

Frequently Asked Questions

Why does CloudFront return AccessDenied instead of 404 for a missing file?

S3 deliberately returns the same AccessDenied response whether an object doesn't exist or exists but isn't readable by the requester. This stops someone without list permissions from probing a bucket to find out which files are actually there. If you have s3:ListBucket permission you'll see a genuine 404 instead, but granting public list access is not a security best practice, so most private buckets show AccessDenied for both cases.

Do I need to make my S3 bucket public to use it as a CloudFront origin?

No. That's the whole point of OAC (and OAI before it): CloudFront signs its requests to S3 with credentials the bucket policy recognizes, so the bucket stays completely private to everyone except your own distribution.

What's the difference between OAC and OAI, and should I still use OAI?

OAC is the current recommendation. It supports every AWS Region including newer opt-in Regions, works with SSE-KMS-encrypted objects, and can sign write requests like PUT and DELETE. OAI supports none of those. Existing distributions on OAI keep working, but new setups should use OAC.

My bucket policy already allows CloudFront, so why is it still failing?

Check for a second statement in the same policy with "Effect": "Deny". A Deny statement overrides any Allow statement, even a correctly written one, and it's easy to have one left over from an earlier, unrelated restriction.

I just fixed the bucket policy and I'm still getting Access Denied — why?

CloudFront caches an AccessDenied response from the origin for up to five minutes. Either wait, or create an invalidation for the affected path so CloudFront goes back to S3 instead of serving the cached error.

Does OAC work with S3 buckets that use SSE-KMS encryption?

Yes, but the bucket policy alone isn't enough. You also need a statement on the KMS key policy that allows the CloudFront service principal, scoped to your distribution's ARN, to call kms:Decrypt (and kms:Encrypt/kms:GenerateDataKey* if you're also uploading through CloudFront).

Can I use OAC with an S3 static website endpoint?

No. If your origin is a website endpoint (the domain includes s3-website), it's treated as a custom HTTP origin and neither OAC nor OAI applies. The objects have to be publicly readable, or you need to switch the origin to the REST API endpoint instead.

Why does S3 return AccessDenied for a file that doesn't exist?

By design — see the first FAQ above. Confirm the object actually exists with the CLI's head-object command before assuming it's a permissions issue.

I migrated from OAI to OAC and now nothing works — what did I break?

Most likely the origin was switched to OAC before the bucket policy was updated to allow the new principal. Add the OAC's bucket policy statement alongside the old OAI statement first, confirm the distribution deploys and serves correctly, then remove the OAI statement.

What Object Ownership setting does OAC require?

Bucket owner enforced, which is the default for new S3 buckets. If an older bucket still uses ACLs and has objects owned by a different AWS account than the bucket itself, bucket-policy-based access won't reach those specific objects until ownership is reconciled.

Does Block Public Access need to be off for OAC to work?

No. OAC doesn't require the bucket to be public at all, so Block Public Access can and should stay on. It only matters here if an earlier attempt made objects public and this setting silently prevented that from taking effect.

Can two AWS accounts share a bucket and distribution without Access Denied issues?

Yes, as long as the bucket policy grants access correctly, but watch object ownership closely: if objects were uploaded by an account other than the one that owns the bucket, bucket-policy access won't cover them until ownership is fixed or the objects are re-copied under the bucket owner's account.

Why do only some of my objects return Access Denied while most work fine?

This usually points to an object-level inconsistency rather than the bucket policy: mismatched object ownership on files uploaded outside the normal pipeline, KMS encryption applied to only some objects, or a case-sensitivity mismatch between the requested path and the stored key. Check the specific failing objects with head-object rather than re-reading the bucket policy again.

Can Service Control Policies (SCPs) cause Access Denied even with a correct bucket policy?

Yes. An SCP attached to your AWS Organization's root, an organizational unit, or your account directly can deny s3:GetObject regardless of what the bucket policy allows. This has to be checked from the Organization's management account — if you don't manage that account yourself, this is one failure mode you can flag but not fix alone.

How do I combine an S3 origin and an API Gateway origin on the same CloudFront distribution?

Add both as separate origins, set the S3 origin as the default (*) behavior, and add a path pattern like /api/* as a second behavior pointed at the API Gateway origin. Use the Origin Path setting on the API origin if CloudFront needs to strip a prefix before forwarding, since CloudFront can add to a path but can't remove part of it on its own.

How long does it take for a bucket policy fix to actually take effect on CloudFront?

The bucket policy change itself is close to immediate on the S3 side. The delay people usually run into is CloudFront's up-to-five-minute cache of the previous AccessDenied response; an invalidation on the affected path clears that immediately.

Revision note. Written August 2026, covering the current Origin Access Control workflow in the CloudFront console, CLI, and CloudFormation, plus the legacy Origin Access Identity for anyone maintaining an older distribution. This will need a revisit if AWS changes the console flow for creating an OAC or adjusts how S3 bucket policies handle the CloudFront service principal. If you're still staring at a blank warranty PDF at midnight, work through this in order — the fix is almost always smaller than it feels right now.

Related