Serve a private S3 bucket through CloudFront with OAC

Put CloudFront in front of a private S3 bucket using origin access control — create the OAC, attach it to a REST-endpoint origin, write the bucket policy scoped to the distribution, add the KMS key statement for SSE-KMS, add a custom domain with a us-east-1 certificate, and migrate off legacy OAI without downtime.

markdownguidev4T3FM-F7ZGstage: buildtrust: officialservice: cloudfrontservice: s3interface: cliinterface: sdkCloudFront S3 originorigin access controlOACOAI legacycloudfront.amazonaws.com bucket policyAWS:SourceArn distributionCreateOriginAccessControlCommandSigningBehavior alwaysSSE-KMS CloudFrontalternate domain nameACM us-east-1S3 website endpoint custom originmigrate OAI to OAC

Created 18 Oct 2026 · Modified 23 Oct 2026 · 4 versions

Keep the bucket private, with Block Public Access on and Object Ownership set to Bucket owner enforced. Give the distribution an origin access control (OAC) that signs every request, and grant cloudfront.amazonaws.com s3:GetObject in the bucket policy, conditioned on that one distribution's ARN. Use the bucket's REST endpoint as the origin, not its static-website endpoint.

When OAC does not apply

  • S3 static website endpoint (<bucket>.s3-website-<region>.amazonaws.com): it must be configured as a custom origin, and neither OAC nor OAI works with it. If you need website features such as index documents in subfolders or redirects, use the REST endpoint plus a CloudFront Function, or accept a public website bucket (UNVERIFIED alternatives).
  • Origin redirects with Lambda@Edge aren't supported with OAC.
  • Multi-Region Access Points need a different OAC configuration.

OAC replaces the legacy origin access identity (OAI), which doesn't support SSE-KMS, PUT/DELETE, or Regions launched since early 2023.

1. Create the OAC

import { CloudFrontClient, CreateOriginAccessControlCommand } from "@aws-sdk/client-cloudfront";

const cf = new CloudFrontClient({ region: "us-east-1" });

const oac = await cf.send(new CreateOriginAccessControlCommand({
  OriginAccessControlConfig: {
    Name: "<bucket>-oac",
    OriginAccessControlOriginType: "s3",
    SigningBehavior: "always",
    SigningProtocol: "sigv4",
  },
}));
console.log(oac.OriginAccessControl?.Id); // put this in the origin's OriginAccessControlId

CLI: aws cloudfront create-origin-access-control --cli-input-yaml file://oac.yaml. Generate the skeleton with --generate-cli-skeleton yaml-input and set SigningBehavior: always.

SigningBehavior:

  • always is recommended. It is also the only setting that guarantees HTTPS between CloudFront and S3.
  • never turns signing off; the bucket must then be public.
  • no-override passes a viewer's Authorization header through, and you must add that header to the cache policy.

The SDK's type also lists always-amz-auth, which the page doesn't describe; don't use it without its documentation.

2. Attach it to the origin

For an existing distribution:

aws cloudfront get-distribution-config --id <dist-id> --output yaml > dist.yaml
# edit: Origins → set OriginAccessControlId: <oac-id>; clear OriginAccessIdentity; rename ETag → IfMatch
aws cloudfront update-distribution --id <dist-id> --cli-input-yaml file://dist.yaml

The origin domain is the REST endpoint, <bucket>.s3.<region>.amazonaws.com. The change deploys to all edge locations (UNVERIFIED duration).

3. Bucket policy (the bucket owner's account)

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

aws s3api put-bucket-policy --bucket <bucket> --policy file://policy.json. Add s3:PutObject only if viewers upload through CloudFront. The AWS:SourceArn condition is what stops other distributions from using the grant. Without s3:ListBucket, a missing key returns 403, not 404.

4. SSE-KMS objects: also the key policy

{
  "Sid": "AllowCloudFrontOACKms",
  "Effect": "Allow",
  "Principal": { "Service": "cloudfront.amazonaws.com" },
  "Action": ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey*"],
  "Resource": "*",
  "Condition": { "StringEquals": { "AWS:SourceArn": "arn:aws:cloudfront::<account>:distribution/<dist-id>" } }
}

Add it to the customer managed key's policy. Objects under the AWS managed aws/s3 key can't be served this way, because an AWS managed key's policy can't be modified (stated in SQS's key-management guide; consistent with reference/s3/encryption.md and reference/kms/key-policies-and-errors.md).

5. Custom domain

  1. Request or import the certificate in ACM in us-east-1 (for viewer HTTPS, whatever Region the bucket is in). It must cover the domain in its SAN field, by exact name or a wildcard at the same level.
  2. Add the domain as an alternate domain name on the distribution. A DNS record pointing at CloudFront without this returns 403.
  3. Create a Route 53 alias record to the distribution (guides/route53/point-domain-at-aws.md).

Migrating from OAI without downtime

  1. Add a second bucket-policy statement for the OAC principal, keeping the OAI statement.
  2. Switch the origin to the OAC and remove OriginAccessIdentity.
  3. After the distribution has fully deployed, remove the OAI statement.

Deleting

Delete the distribution before the bucket, or remove the OAC first.

When it returns 403

The usual causes are a bucket policy without the OAC statement or with the wrong distribution ARN; a wrong path or missing object; the KMS key policy; Host header forwarding to the REST endpoint; or Restrict Viewer Access without signed URLs. See reference/cloudfront/errors.md, then guides/s3/fix-403.md for the S3 side.

Read this document as markdown — the same bytes an agent receives.