This is the complete solution for the CloudFront Distribution Exercise. Every command is shown with its expected output.
Why This Works
- Origin Access Control keeps S3 private while still allowing CloudFront to read the files your users need.
- Custom error responses make a single-page app behave like a frontend router instead of a pile of missing-object errors.
- The distribution becomes the enforcement point for HTTPS, caching, and security headers, which is why it sits between your users and the bucket.
If the console or CLI output shifts while you’re doing this, keep the CloudFront Developer Guide and the OAC setup guide open.
Create an Origin Access Control
aws cloudfront create-origin-access-control \
--origin-access-control-config '{
"Name": "my-frontend-app-oac",
"Description": "OAC for my-frontend-app-assets S3 bucket",
"SigningProtocol": "sigv4",
"SigningBehavior": "always",
"OriginAccessControlOriginType": "s3"
}' \
--region us-east-1 \
--output jsonExpected output:
{
"Location": "https://cloudfront.amazonaws.com/2020-05-31/origin-access-control/E1OAC2EXAMPLE",
"ETag": "E1ETAG1EXAMPLE",
"OriginAccessControl": {
"Id": "E1OAC2EXAMPLE",
"OriginAccessControlConfig": {
"Name": "my-frontend-app-oac",
"Description": "OAC for my-frontend-app-assets S3 bucket",
"SigningProtocol": "sigv4",
"SigningBehavior": "always",
"OriginAccessControlOriginType": "s3"
}
}
}Save the Id value (E1OAC2EXAMPLE).
Create the Distribution
Save the following as distribution-config.json:
{
"CallerReference": "my-frontend-app-2026-03-18",
"Comment": "CloudFront distribution for my-frontend-app-assets",
"Enabled": true,
"DefaultRootObject": "index.html",
"PriceClass": "PriceClass_100",
"HttpVersion": "http2and3",
"IsIPV6Enabled": true,
"Origins": {
"Quantity": 1,
"Items": [
{
"Id": "S3-my-frontend-app-assets",
"DomainName": "my-frontend-app-assets.s3.us-east-1.amazonaws.com",
"OriginAccessControlId": "E1OAC2EXAMPLE",
"S3OriginConfig": {
"OriginAccessIdentity": ""
}
}
]
},
"DefaultCacheBehavior": {
"TargetOriginId": "S3-my-frontend-app-assets",
"ViewerProtocolPolicy": "redirect-to-https",
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
"Compress": true,
"AllowedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"],
"CachedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"]
}
}
},
"CustomErrorResponses": {
"Quantity": 2,
"Items": [
{
"ErrorCode": 403,
"ResponsePagePath": "/index.html",
"ResponseCode": "200",
"ErrorCachingMinTTL": 10
},
{
"ErrorCode": 404,
"ResponsePagePath": "/index.html",
"ResponseCode": "200",
"ErrorCachingMinTTL": 10
}
]
},
"ViewerCertificate": {
"ACMCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"SSLSupportMethod": "sni-only",
"MinimumProtocolVersion": "TLSv1.2_2021"
},
"Aliases": {
"Quantity": 2,
"Items": ["example.com", "www.example.com"]
},
"Restrictions": {
"GeoRestriction": {
"RestrictionType": "none",
"Quantity": 0
}
}
}Create the distribution:
aws cloudfront create-distribution \
--distribution-config file://distribution-config.json \
--region us-east-1 \
--output jsonExpected output (abridged to the fields you need):
{
"Distribution": {
"Id": "E1A2B3C4D5E6F7",
"ARN": "arn:aws:cloudfront::123456789012:distribution/E1A2B3C4D5E6F7",
"DomainName": "d1234abcdef.cloudfront.net",
"Status": "InProgress",
"DistributionConfig": {
"CallerReference": "my-frontend-app-2026-03-18",
"Comment": "CloudFront distribution for my-frontend-app-assets",
"Enabled": true,
"DefaultRootObject": "index.html",
"PriceClass": "PriceClass_100",
"HttpVersion": "http2and3",
"IsIPV6Enabled": true,
"Origins": {
"Quantity": 1,
"Items": [
{
"Id": "S3-my-frontend-app-assets",
"DomainName": "my-frontend-app-assets.s3.us-east-1.amazonaws.com",
"OriginAccessControlId": "E1OAC2EXAMPLE",
"S3OriginConfig": {
"OriginAccessIdentity": ""
}
}
]
},
"DefaultCacheBehavior": {
"TargetOriginId": "S3-my-frontend-app-assets",
"ViewerProtocolPolicy": "redirect-to-https",
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
"Compress": true,
"AllowedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"],
"CachedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"]
}
}
},
"CustomErrorResponses": {
"Quantity": 2,
"Items": [
{
"ErrorCode": 403,
"ResponsePagePath": "/index.html",
"ResponseCode": "200",
"ErrorCachingMinTTL": 10
},
{
"ErrorCode": 404,
"ResponsePagePath": "/index.html",
"ResponseCode": "200",
"ErrorCachingMinTTL": 10
}
]
},
"ViewerCertificate": {
"ACMCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"SSLSupportMethod": "sni-only",
"MinimumProtocolVersion": "TLSv1.2_2021"
},
"Aliases": {
"Quantity": 2,
"Items": ["example.com", "www.example.com"]
},
"Restrictions": {
"GeoRestriction": {
"RestrictionType": "none",
"Quantity": 0
}
}
}
}
}Save the distribution ID (E1A2B3C4D5E6F7) and domain name (d1234abcdef.cloudfront.net).
Wait for the distribution to deploy:
aws cloudfront wait distribution-deployed \
--id E1A2B3C4D5E6F7 \
--region us-east-1This command blocks until the status changes from InProgress to Deployed. No output means success.
Update the S3 Bucket Policy
Save the following as bucket-policy-oac.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-frontend-app-assets/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/E1A2B3C4D5E6F7"
}
}
}
]
}Apply the policy:
aws s3api put-bucket-policy \
--bucket my-frontend-app-assets \
--policy file://bucket-policy-oac.json \
--region us-east-1No output on success.
Verify the policy:
aws s3api get-bucket-policy \
--bucket my-frontend-app-assets \
--region us-east-1 \
--output jsonTest CloudFront access:
curl -I https://d1234abcdef.cloudfront.net/index.htmlExpected:
HTTP/2 200
content-type: text/htmlTest that direct S3 access is blocked:
curl -I https://my-frontend-app-assets.s3.us-east-1.amazonaws.com/index.htmlExpected:
HTTP/1.1 403 ForbiddenRe-enable Block Public Access
aws s3api put-public-access-block \
--bucket my-frontend-app-assets \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true" \
--region us-east-1No output on success.
Verify Block Public Access is enabled:
aws s3api get-public-access-block \
--bucket my-frontend-app-assets \
--region us-east-1 \
--output jsonExpected:
{
"PublicAccessBlockConfiguration": {
"BlockPublicAcls": true,
"IgnorePublicAcls": true,
"BlockPublicPolicy": true,
"RestrictPublicBuckets": true
}
}Verify CloudFront still works:
curl -I https://d1234abcdef.cloudfront.net/index.htmlStill 200 OK. The CloudFront service principal policy isn’t considered a “public” policy.
Test SPA Routing
curl -I https://d1234abcdef.cloudfront.net/dashboard/settingsExpected:
HTTP/2 200
content-type: text/htmlThe custom error response intercepts the 403 from S3 (because /dashboard/settings doesn’t exist as an object) and returns /index.html with a 200 status code. Your client-side router handles the rest.
Test another non-existent path:
curl -I https://d1234abcdef.cloudfront.net/users/123/profileSame result: 200 OK with text/html.
Attach the Security Headers Policy
Fetch the current distribution config:
aws cloudfront get-distribution-config \
--id E1A2B3C4D5E6F7 \
--region us-east-1 \
--output json > distribution-config-current.jsonNote the ETag in the response (e.g., E3ETAG3EXAMPLE).
Edit the DistributionConfig to add ResponseHeadersPolicyId to the DefaultCacheBehavior. The updated behavior should look like this:
{
"DefaultCacheBehavior": {
"TargetOriginId": "S3-my-frontend-app-assets",
"ViewerProtocolPolicy": "redirect-to-https",
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
"ResponseHeadersPolicyId": "67f7725c-6f97-4210-82d7-5512b31e9d03",
"Compress": true,
"AllowedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"],
"CachedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"]
}
}
}
}Extract the DistributionConfig (without the ETag wrapper) into distribution-config-updated.json and submit:
aws cloudfront update-distribution \
--id E1A2B3C4D5E6F7 \
--if-match E3ETAG3EXAMPLE \
--distribution-config file://distribution-config-updated.json \
--region us-east-1 \
--output jsonWait for deployment:
aws cloudfront wait distribution-deployed \
--id E1A2B3C4D5E6F7 \
--region us-east-1Verify the security headers:
curl -I https://d1234abcdef.cloudfront.net/index.htmlExpected (among other headers):
strict-transport-security: max-age=31536000
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-xss-protection: 1; mode=block
referrer-policy: strict-origin-when-cross-originFinal Verification
Run all checks:
# Distribution status
aws cloudfront get-distribution \
--id E1A2B3C4D5E6F7 \
--region us-east-1 \
--output json \
--query "Distribution.{Id:Id,Domain:DomainName,Status:Status}"Expected:
{
"Id": "E1A2B3C4D5E6F7",
"Domain": "d1234abcdef.cloudfront.net",
"Status": "Deployed"
}# HTTPS works
curl -I https://d1234abcdef.cloudfront.net
# SPA routing works
curl -I https://d1234abcdef.cloudfront.net/any/spa/route
# S3 is locked down
curl -I https://my-frontend-app-assets.s3.us-east-1.amazonaws.com/index.htmlExpected results: CloudFront returns 200 OK for both the root and the SPA route. Direct S3 access returns 403 Forbidden.
Summary of Resources Created
| Resource | Identifier |
|---|---|
| Origin Access Control | E1OAC2EXAMPLE |
| CloudFront Distribution | E1A2B3C4D5E6F7 |
| Distribution Domain | d1234abcdef.cloudfront.net |
| S3 Bucket Policy | Updated to allow only CloudFront |
| Response Headers Policy | 67f7725c-6f97-4210-82d7-5512b31e9d03 (managed SecurityHeadersPolicy) |
| Cache Policy | 658327ea-f89d-4fab-a63d-7e88639e58f6 (managed CachingOptimized) |
Your distribution is live, secured, and ready for a custom domain. That’s what the Route 53 custom-domain-routing section handles next.