Questions
Implement S3 with CloudFront for secure, cached content delivery with signed URLs.
The Scenario
You need to serve static assets and user-uploaded content:
Requirements:
├── Static assets: JS, CSS, images (public)
├── User content: PDFs, documents (private, signed URLs)
├── Global users: Low latency worldwide
├── Security: No direct S3 access, HTTPS only
├── Cost: Optimize data transfer costs
└── Performance: Cache control, compression
The Challenge
Implement a secure, performant content delivery architecture using S3 as origin and CloudFront as CDN, with proper cache policies and access controls.
A junior engineer might make the S3 bucket public, skip CloudFront, use S3 website hosting for everything, or ignore cache headers. These approaches expose data to unauthorized access, increase latency for global users, result in higher costs, and cause poor cache performance.
A senior engineer uses Origin Access Control (OAC) to restrict S3 access, configures appropriate cache policies per content type, implements signed URLs for private content, and uses CloudFront functions for edge processing.
Step 1: Architecture Overview
Content Delivery Architecture:
┌─────────────────┐
│ CloudFront │
│ Functions │
│ (URL rewrite) │
└────────┬────────┘
│
┌─────────────┐ ┌───────────────────────────────────────┐
│ Client │────►│ CloudFront Distribution │
└─────────────┘ │ │
│ ┌─────────┐ ┌─────────┐ ┌────────┐ │
│ │ Cache │ │ Cache │ │ Cache │ │
│ │ Behavior│ │ Behavior│ │Behavior│ │
│ │ /static │ │ /api/* │ │ /* │ │
│ └────┬────┘ └────┬────┘ └───┬────┘ │
└───────┼────────────┼──────────┼───────┘
│ │ │
▼ ▼ ▼
┌───────────┐ ┌─────────┐ ┌───────────┐
│ S3 Bucket│ │ ALB │ │ S3 Bucket │
│ (static) │ │ (API) │ │ (private) │
└───────────┘ └─────────┘ └───────────┘Step 2: S3 Buckets Configuration
# Static assets bucket
resource "aws_s3_bucket" "static" {
bucket = "myapp-static-assets"
}
resource "aws_s3_bucket_versioning" "static" {
bucket = aws_s3_bucket.static.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "static" {
bucket = aws_s3_bucket.static.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# Block all public access
resource "aws_s3_bucket_public_access_block" "static" {
bucket = aws_s3_bucket.static.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Private content bucket
resource "aws_s3_bucket" "private" {
bucket = "myapp-private-content"
}
resource "aws_s3_bucket_versioning" "private" {
bucket = aws_s3_bucket.private.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "private" {
bucket = aws_s3_bucket.private.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.s3.arn
}
}
}
resource "aws_s3_bucket_public_access_block" "private" {
bucket = aws_s3_bucket.private.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Lifecycle policy for cost optimization
resource "aws_s3_bucket_lifecycle_configuration" "private" {
bucket = aws_s3_bucket.private.id
rule {
id = "move-to-ia"
status = "Enabled"
transition {
days = 30
storage_class = "STANDARD_IA"
}
transition {
days = 90
storage_class = "GLACIER_IR"
}
noncurrent_version_transition {
noncurrent_days = 30
storage_class = "STANDARD_IA"
}
noncurrent_version_expiration {
noncurrent_days = 90
}
}
}Step 3: CloudFront Distribution with OAC
# Origin Access Control
resource "aws_cloudfront_origin_access_control" "main" {
name = "s3-oac"
description = "OAC for S3 buckets"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
# CloudFront Distribution
resource "aws_cloudfront_distribution" "main" {
enabled = true
is_ipv6_enabled = true
comment = "Main CDN distribution"
default_root_object = "index.html"
price_class = "PriceClass_100" # US, Canada, Europe
aliases = ["cdn.example.com"]
# Static assets origin
origin {
domain_name = aws_s3_bucket.static.bucket_regional_domain_name
origin_id = "S3-static"
origin_access_control_id = aws_cloudfront_origin_access_control.main.id
}
# Private content origin
origin {
domain_name = aws_s3_bucket.private.bucket_regional_domain_name
origin_id = "S3-private"
origin_access_control_id = aws_cloudfront_origin_access_control.main.id
}
# API origin
origin {
domain_name = aws_lb.api.dns_name
origin_id = "ALB-api"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
# Default behavior (static assets)
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "S3-static"
viewer_protocol_policy = "redirect-to-https"
compress = true
cache_policy_id = aws_cloudfront_cache_policy.static.id
origin_request_policy_id = aws_cloudfront_origin_request_policy.s3.id
function_association {
event_type = "viewer-request"
function_arn = aws_cloudfront_function.url_rewrite.arn
}
}
# Private content behavior (signed URLs required)
ordered_cache_behavior {
path_pattern = "/private/*"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "S3-private"
viewer_protocol_policy = "https-only"
compress = true
# Require signed URLs
trusted_key_groups = [aws_cloudfront_key_group.main.id]
cache_policy_id = aws_cloudfront_cache_policy.private.id
}
# API behavior (no caching)
ordered_cache_behavior {
path_pattern = "/api/*"
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "ALB-api"
viewer_protocol_policy = "https-only"
compress = true
cache_policy_id = aws_cloudfront_cache_policy.api.id
origin_request_policy_id = aws_cloudfront_origin_request_policy.api.id
}
# Custom error responses
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html" # SPA fallback
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
acm_certificate_arn = aws_acm_certificate.cdn.arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
tags = {
Name = "main-cdn"
}
}
# S3 bucket policy for CloudFront OAC
resource "aws_s3_bucket_policy" "static" {
bucket = aws_s3_bucket.static.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowCloudFrontOAC"
Effect = "Allow"
Principal = {
Service = "cloudfront.amazonaws.com"
}
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.static.arn}/*"
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.main.arn
}
}
}
]
})
}Step 4: Cache Policies
# Cache policy for static assets (long TTL)
resource "aws_cloudfront_cache_policy" "static" {
name = "static-assets"
comment = "Cache policy for static assets"
default_ttl = 86400 # 1 day
max_ttl = 31536000 # 1 year
min_ttl = 1
parameters_in_cache_key_and_forwarded_to_origin {
cookies_config {
cookie_behavior = "none"
}
headers_config {
header_behavior = "none"
}
query_strings_config {
query_string_behavior = "none"
}
enable_accept_encoding_brotli = true
enable_accept_encoding_gzip = true
}
}
# Cache policy for private content (shorter TTL)
resource "aws_cloudfront_cache_policy" "private" {
name = "private-content"
comment = "Cache policy for private content"
default_ttl = 3600 # 1 hour
max_ttl = 86400 # 1 day
min_ttl = 0
parameters_in_cache_key_and_forwarded_to_origin {
cookies_config {
cookie_behavior = "none"
}
headers_config {
header_behavior = "none"
}
query_strings_config {
query_string_behavior = "none"
}
enable_accept_encoding_brotli = true
enable_accept_encoding_gzip = true
}
}
# Cache policy for API (no caching)
resource "aws_cloudfront_cache_policy" "api" {
name = "api-no-cache"
comment = "No caching for API requests"
default_ttl = 0
max_ttl = 0
min_ttl = 0
parameters_in_cache_key_and_forwarded_to_origin {
cookies_config {
cookie_behavior = "all"
}
headers_config {
header_behavior = "whitelist"
headers {
items = ["Authorization", "Content-Type"]
}
}
query_strings_config {
query_string_behavior = "all"
}
}
}
# Origin request policy for API
resource "aws_cloudfront_origin_request_policy" "api" {
name = "api-origin-request"
comment = "Forward all headers to API"
cookies_config {
cookie_behavior = "all"
}
headers_config {
header_behavior = "allViewer"
}
query_strings_config {
query_string_behavior = "all"
}
}Step 5: Signed URLs for Private Content
import datetime
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
import base64
import json
import boto3
class CloudFrontSigner:
def __init__(self, key_pair_id: str, private_key_pem: str):
self.key_pair_id = key_pair_id
self.private_key = serialization.load_pem_private_key(
private_key_pem.encode(),
password=None,
backend=default_backend()
)
def create_signed_url(
self,
url: str,
expires_at: datetime.datetime,
ip_address: str = None
) -> str:
"""Generate a signed URL for CloudFront."""
policy = self._create_policy(url, expires_at, ip_address)
signature = self._sign(policy)
# URL-safe base64
policy_b64 = self._url_safe_b64(policy.encode())
signature_b64 = self._url_safe_b64(signature)
separator = '&' if '?' in url else '?'
return f"{url}{separator}Policy={policy_b64}&Signature={signature_b64}&Key-Pair-Id={self.key_pair_id}"
def _create_policy(
self,
url: str,
expires_at: datetime.datetime,
ip_address: str = None
) -> str:
"""Create CloudFront policy document."""
policy = {
"Statement": [{
"Resource": url,
"Condition": {
"DateLessThan": {
"AWS:EpochTime": int(expires_at.timestamp())
}
}
}]
}
if ip_address:
policy["Statement"][0]["Condition"]["IpAddress"] = {
"AWS:SourceIp": ip_address
}
return json.dumps(policy, separators=(',', ':'))
def _sign(self, message: str) -> bytes:
"""Sign message with RSA private key."""
return self.private_key.sign(
message.encode(),
padding.PKCS1v15(),
None
)
def _url_safe_b64(self, data: bytes) -> str:
"""URL-safe base64 encoding."""
return base64.b64encode(data).decode().replace('+', '-').replace('=', '_').replace('/', '~')
# Usage in Lambda
def get_signed_url(event, context):
"""Generate signed URL for private content."""
secrets = boto3.client('secretsmanager')
# Get private key from Secrets Manager
secret = secrets.get_secret_value(SecretId='cloudfront/private-key')
private_key = secret['SecretString']
signer = CloudFrontSigner(
key_pair_id=os.environ['CLOUDFRONT_KEY_PAIR_ID'],
private_key_pem=private_key
)
# Generate URL valid for 1 hour
expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=1)
signed_url = signer.create_signed_url(
url=f"https://cdn.example.com/private/{event['file_key']}",
expires_at=expires_at,
ip_address=event.get('requestContext', {}).get('identity', {}).get('sourceIp')
)
return {
'statusCode': 200,
'body': json.dumps({'url': signed_url})
}Step 6: CloudFront Functions
// URL Rewrite Function (viewer-request)
function handler(event) {
var request = event.request;
var uri = request.uri;
// Add index.html to directory requests
if (uri.endsWith('/')) {
request.uri += 'index.html';
}
// Handle SPA routing - serve index.html for routes without extensions
else if (!uri.includes('.')) {
request.uri = '/index.html';
}
return request;
}resource "aws_cloudfront_function" "url_rewrite" {
name = "url-rewrite"
runtime = "cloudfront-js-1.0"
comment = "URL rewrite for SPA"
publish = true
code = file("${path.module}/functions/url-rewrite.js")
}
# Security headers function
resource "aws_cloudfront_function" "security_headers" {
name = "security-headers"
runtime = "cloudfront-js-1.0"
comment = "Add security headers"
publish = true
code = <<-EOF
function handler(event) {
var response = event.response;
var headers = response.headers;
headers['strict-transport-security'] = { value: 'max-age=63072000; includeSubdomains; preload' };
headers['x-content-type-options'] = { value: 'nosniff' };
headers['x-frame-options'] = { value: 'DENY' };
headers['x-xss-protection'] = { value: '1; mode=block' };
headers['referrer-policy'] = { value: 'strict-origin-when-cross-origin' };
return response;
}
EOF
} CloudFront Best Practices
| Feature | Configuration | Purpose |
|---|---|---|
| Origin Access Control | Always for S3 | Prevent direct S3 access |
| Cache Policy | Per content type | Optimize cache hit ratio |
| Compression | Enable Brotli + Gzip | Reduce bandwidth costs |
| Price Class | Match user locations | Balance cost/performance |
| Signed URLs | Private content | Secure time-limited access |
Practice Question
Why should you use Origin Access Control (OAC) instead of Origin Access Identity (OAI) for new CloudFront distributions?