DeployU
Interviews / Cloud & DevOps / Implement S3 with CloudFront for secure, cached content delivery with signed URLs.

Implement S3 with CloudFront for secure, cached content delivery with signed URLs.

practical Storage & CDN Interactive Quiz Code Examples

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.

Wrong Approach

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.

Right Approach

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

FeatureConfigurationPurpose
Origin Access ControlAlways for S3Prevent direct S3 access
Cache PolicyPer content typeOptimize cache hit ratio
CompressionEnable Brotli + GzipReduce bandwidth costs
Price ClassMatch user locationsBalance cost/performance
Signed URLsPrivate contentSecure time-limited access

Practice Question

Why should you use Origin Access Control (OAC) instead of Origin Access Identity (OAI) for new CloudFront distributions?