| name | cloudfront-s3-oac |
| description | Securely serve private S3 content through CloudFront using Origin Access Control (OAC). Use when you need to make S3 files publicly accessible over a CDN without making the bucket itself public — e.g. hosting static sites, media, downloads, or build artifacts. Covers the modern OAC approach (AWS-recommended), migrating legacy OAI distributions to OAC, locking down the bucket Public Access Block, enforcing HTTPS, and verifying the result. Never make an S3 bucket public directly. |
CloudFront + S3 with Origin Access Control (OAC)
Overview
The goal: let the public download S3 objects through a CloudFront CDN, while the
S3 bucket stays fully private. CloudFront authenticates to S3 with a signed
identity; the bucket policy only trusts that one CloudFront distribution. The
public never touches S3 directly.
Use OAC, not OAI. Origin Access Control (OAC) is the current AWS-recommended
mechanism. Origin Access Identity (OAI) is legacy — it does not support
SSE-KMS-encrypted buckets, fails in newer regions, and AWS has stopped investing
in it. New setups use OAC; existing OAI distributions should be migrated.
Public user → custom domain (e.g. cdn.example.com)
→ CloudFront distribution (dxxxx.cloudfront.net)
→ CloudFront signs request with OAC (SigV4)
→ reads PRIVATE S3 bucket
→ returns object to user
Placeholders used in this guide
Replace these throughout with your own values:
<BUCKET_NAME> — your S3 bucket name
<REGION> — bucket region, e.g. us-east-1
<ACCOUNT_ID> — your 12-digit AWS account ID
<DISTRIBUTION_ID> — the CloudFront distribution ID (e.g. E…)
<OAC_ID> — the Origin Access Control ID
<CDN_DOMAIN> — the public domain (custom alias or dxxxx.cloudfront.net)
Security Rules (MUST follow)
- MUST NEVER make the S3 bucket public. No
Principal: "*" in the bucket
policy. No public ACLs. The only reader is the CloudFront distribution.
- MUST turn ALL four Public Access Block switches ON (
true). OAC reads the
bucket via the bucket policy, which Public Access Block does NOT interfere with.
Leaving them false means a single fat-fingered policy edit silently exposes
everything. Consequence: a private media bucket becomes a public data leak.
- MUST scope the bucket policy to the exact distribution ARN via the
AWS:SourceArn condition, so only THAT distribution can read — not any
CloudFront distribution in any account.
- MUST enforce HTTPS — set the viewer protocol policy to
redirect-to-https,
never allow-all (which serves plaintext HTTP).
- Before any change to an existing distribution/bucket, back up the current
config to JSON and state the rollback path. These are live public endpoints.
Quick Start — brand-new OAC setup
Assumes a private bucket <BUCKET_NAME> in region <REGION> already holds the files.
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
BUCKET=<BUCKET_NAME>
REGION=<REGION>
aws s3api put-public-access-block --bucket "$BUCKET" \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
OAC_ID=$(aws cloudfront create-origin-access-control \
--origin-access-control-config \
"Name=${BUCKET}-oac,Description=OAC for ${BUCKET},SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=s3" \
--query 'OriginAccessControl.Id' --output text)
echo "OAC_ID=$OAC_ID"
Then attach the bucket policy (replace the three placeholders):
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowCloudFrontServicePrincipalReadOnly",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::<BUCKET_NAME>/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::<ACCOUNT_ID>:distribution/<DISTRIBUTION_ID>"
}
}
}]
}
aws s3api put-bucket-policy --bucket "$BUCKET" --policy file://bucket-policy.json
Migrating a legacy OAI distribution to OAC
Symptom you'll find on old setups: the distribution origin has
S3OriginConfig.OriginAccessIdentity = origin-access-identity/cloudfront/<OAI_ID>
and the bucket policy trusts a principal like
arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity <OAI_ID>.
Migration steps (no file movement, fully reversible):
- Back up first:
aws cloudfront get-distribution-config --id <DISTRIBUTION_ID> > dist-backup.json
aws s3api get-bucket-policy --bucket <BUCKET_NAME> --query Policy --output text > policy-backup.json
- Create an OAC (step 2 above).
get-distribution-config → edit the JSON: set OriginAccessControlId to the
new OAC, blank out S3OriginConfig.OriginAccessIdentity (set to ""), keep
the ETag for the --if-match flag.
update-distribution --id <DISTRIBUTION_ID> --if-match <ETAG> --distribution-config file://edited.json
- Replace the bucket policy with the OAC-style policy (Service principal +
AWS:SourceArn condition) shown above.
- Flip the Public Access Block to all-
true if it wasn't already.
- Wait for the distribution to redeploy (
Status: Deployed), then verify.
Keep the old OAI around until verification passes; delete it only after.
Verification (always do this before declaring done)
curl -sI https://<CDN_DOMAIN>/path/to/object | head -5
curl -sI https://<BUCKET_NAME>.s3.<REGION>.amazonaws.com/path/to/object | head -3
curl -sI http://<CDN_DOMAIN>/path/to/object | grep -i location
aws s3api get-public-access-block --bucket <BUCKET_NAME>
If step 2 returns 200, the bucket is leaking — stop and fix the Public Access
Block + bucket policy before telling anyone it's done.
Pitfalls
- Website endpoint vs REST endpoint: OAC requires the S3 REST endpoint
(
<BUCKET_NAME>.s3.<REGION>.amazonaws.com). The S3 website endpoint
(<BUCKET_NAME>.s3-website-...) does NOT support OAC and forces the bucket
public. If you need S3 website features (index docs, redirects), use a
CloudFront Function / default root object instead of the website endpoint.
- KMS-encrypted buckets: OAC supports SSE-KMS (OAI did not). The KMS key
policy must also allow the CloudFront service principal to
kms:Decrypt.
- 403 after migration: usually the bucket policy still trusts the old OAI,
or the
AWS:SourceArn distribution ID is wrong. Re-check both.
- Default root object: set
DefaultRootObject=index.html for static sites,
or curl to / returns 403/no-key errors.
- Cache after updates: changed an object but CDN serves stale? Invalidate:
aws cloudfront create-invalidation --distribution-id <DISTRIBUTION_ID> --paths "/*".
References
See references/distribution-config.json for a complete, ready-to-edit
create-distribution config wired for an S3 origin + OAC + redirect-to-https.