AWS CloudFront Security: OAC, Signed URLs and WAF Integration (2026)

CloudFront Security

AWS CloudFront is not just a content delivery network — it is your first line of defence against DDoS attacks, unauthorized access, and data exfiltration. In 2026, CloudFront's security surface spans Origin Access Control (OAC), signed URLs and cookies, AWS WAF integration, response-header policies, field-level encryption, geo-restriction, and mandatory HTTPS enforcement. This guide walks through every layer with working code examples so you can lock down your distribution completely.

Whether you are migrating a legacy Origin Access Identity (OAI) setup to the modern OAC model, building a time-limited video streaming URL, or attaching a WAF WebACL to block SQL injection at the edge, the patterns here are production-ready and follow AWS Well-Architected Security Pillar guidelines.

Table of Contents

  1. CloudFront Security Overview & Threat Model
  2. Origin Access Control (OAC) vs OAI — Migration Guide
  3. Signed URLs: Use Cases & Python Implementation
  4. Signed Cookies for Multi-File Access
  5. Key Groups and Public Key Rotation
  6. WAF Integration: WebACL, Rate Limits & Geo-Blocking
  7. Security Headers via Response Headers Policy
  8. Field-Level Encryption
  9. Geo-Restriction: Allowlist & Blocklist
  10. HTTPS Enforcement & TLS Configuration
  11. Monitoring: Access Logs & Real-Time Logs

1. CloudFront Security Overview & Threat Model

CloudFront sits between your end users and your origin (S3, ALB, API Gateway, or a custom HTTP server). Every request flows through one of AWS's 600+ edge locations before reaching your origin. This topology creates natural choke points where you can intercept, inspect, and block malicious traffic.

What CloudFront Protects Against

  • Direct-origin attacks — OAC/OAI ensures only CloudFront can read your S3 bucket; public bucket access is blocked.
  • DDoS & volumetric floods — AWS Shield Standard is automatically included with every CloudFront distribution at no extra cost. AWS Shield Advanced adds 24/7 DRT support and cost protection.
  • Web application attacks — AWS WAF (attached to CloudFront) filters SQLi, XSS, bad bots, and custom threat signatures at the edge before traffic reaches your origin.
  • Unauthorized content access — Signed URLs and signed cookies restrict private content to authenticated, time-limited requests.
  • Data in transit interexposure — Minimum TLS version policies and HTTPS-redirect behaviours ensure all communication is encrypted.
  • Sensitive field exposure — Field-level encryption encrypts specific form fields (e.g., credit card numbers) at the edge using your public key, so even CloudFront edge nodes never see plaintext.
Architecture tip: Treat CloudFront as a security perimeter, not just a cache. Apply WAF, signed URLs, and security headers even on distributions that serve only public content — rate-limiting and bot management alone typically reduce origin load by 30–60%.

2. Origin Access Control (OAC) vs OAI — Migration Guide

Origin Access Identity (OAI) was CloudFront's original mechanism for granting private S3 access. AWS deprecated OAI for new distributions in 2022 and recommends all customers migrate to Origin Access Control (OAC), which supports SSE-KMS encrypted buckets and server-side signing.

Key Differences

FeatureOAIOAC
SSE-KMS supportNoYes
Signing protocolCustom (deprecated)AWS SigV4
MediaStore / MediaPackage originsNoYes
Lambda Function URL originsNoYes
RecommendedNo (legacy)Yes

Step 1 — Create an OAC via AWS CLI

# Create an Origin Access Control for S3
aws cloudfront create-origin-access-control \
  --origin-access-control-config '{
    "Name": "my-s3-oac",
    "Description": "OAC for my-private-bucket",
    "SigningProtocol": "sigv4",
    "SigningBehavior": "always",
    "OriginAccessControlOriginType": "s3"
  }'
# Note the returned Id — you will reference it in your distribution config

Step 2 — Update Distribution to Use OAC

// Partial distribution config (Origins section)
{
  "Origins": {
    "Quantity": 1,
    "Items": [
      {
        "Id": "my-s3-origin",
        "DomainName": "my-private-bucket.s3.us-east-1.amazonaws.com",
        "S3OriginConfig": {
          "OriginAccessIdentity": ""
        },
        "OriginAccessControlId": "E3NF69ABCEXAMPLE"
      }
    ]
  }
}

Step 3 — Update S3 Bucket Policy

Remove the old OAI policy and apply the OAC policy. Replace DISTRIBUTION_ARN and BUCKET_NAME:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontOAC",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-private-bucket/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/EDFDVBD6EXAMPLE"
        }
      }
    }
  ]
}
KMS-encrypted buckets: If your S3 objects are encrypted with SSE-KMS, also add kms:Decrypt permission to the key policy for cloudfront.amazonaws.com, conditioned on the distribution ARN. OAI cannot do this — it is one of the primary reasons to migrate.

After applying the bucket policy, block all public access on the bucket to ensure S3 refuses any request that does not originate from CloudFront:

aws s3api put-public-access-block \
  --bucket my-private-bucket \
  --public-access-block-configuration \
  "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

3. Signed URLs: Use Cases & Python Implementation

Signed URLs grant time-limited access to a specific CloudFront resource. They are ideal for:

  • Pay-per-view video downloads
  • Software licence file distribution
  • Single-user document downloads after payment
  • Pre-signed upload links for authenticated users

Signed URL Anatomy

A CloudFront signed URL looks like:

https://d1234abcde.cloudfront.net/private/video.mp4
  ?Expires=1749600000
  &Signature=abc123...XYZ~
  &Key-Pair-Id=APKAEIBAERJR2EXAMPLE
  • Expires — Unix timestamp after which the URL is invalid.
  • Signature — RSA-SHA1 signature of a policy document, Base64-encoded (URL-safe).
  • Key-Pair-Id — Public key ID registered in the CloudFront key group.

Generating Signed URLs with Python (boto3 + botocore)

import datetime
from botocore.signers import CloudFrontSigner
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend

# Load your private key (PEM format, keep this in Secrets Manager!)
with open("private_key.pem", "rb") as f:
    private_key = serialization.load_pem_private_key(
        f.read(), password=None, backend=default_backend()
    )

def rsa_signer(message):
    """Signs the policy document using RSA-SHA1."""
    return private_key.sign(message, padding.PKCS1v15(), hashes.SHA1())

KEY_PAIR_ID = "APKAEIBAERJR2EXAMPLE"
CLOUDFRONT_DOMAIN = "https://d1234abcde.cloudfront.net"

cf_signer = CloudFrontSigner(KEY_PAIR_ID, rsa_signer)

# Signed URL valid for 1 hour
url = cf_signer.generate_presigned_url(
    url=f"{CLOUDFRONT_DOMAIN}/private/video.mp4",
    date_less_than=datetime.datetime.utcnow() + datetime.timedelta(hours=1)
)
print(url)
Security note: Never embed the private key in application code or environment variables in plaintext. Store it in AWS Secrets Manager and retrieve it at runtime. Rotate keys every 90 days using CloudFront key groups (see Section 5).

4. Signed Cookies for Multi-File Access

While signed URLs restrict access to a single object, signed cookies grant access to multiple files matching a URL pattern — perfect for streaming platforms where a user pays for access to an entire folder of content (e.g., all episodes of a course).

CloudFront reads three Set-Cookie headers: CloudFront-Policy, CloudFront-Signature, and CloudFront-Key-Pair-Id.

Python: Generate Signed Cookie Headers

import json
import datetime
import base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend

with open("private_key.pem", "rb") as f:
    private_key = serialization.load_pem_private_key(
        f.read(), password=None, backend=default_backend()
    )

KEY_PAIR_ID = "APKAEIBAERJR2EXAMPLE"
RESOURCE_PATTERN = "https://d1234abcde.cloudfront.net/course/module1/*"
EXPIRE_EPOCH = int(
    (datetime.datetime.utcnow() + datetime.timedelta(hours=8)).timestamp()
)

# Build the custom policy document
policy = {
    "Statement": [{
        "Resource": RESOURCE_PATTERN,
        "Condition": {
            "DateLessThan": {"AWS:EpochTime": EXPIRE_EPOCH}
        }
    }]
}
policy_json = json.dumps(policy, separators=(",", ":"))
policy_b64 = base64.b64encode(policy_json.encode()).decode()

# Sign the policy
signature_bytes = private_key.sign(
    policy_json.encode(), padding.PKCS1v15(), hashes.SHA1()
)
# CloudFront uses a URL-safe variant of Base64
def cf_b64(data: bytes) -> str:
    return base64.b64encode(data).decode().replace("+", "-").replace("=", "_").replace("/", "~")

cookies = {
    "CloudFront-Policy": cf_b64(policy_json.encode()),
    "CloudFront-Signature": cf_b64(signature_bytes),
    "CloudFront-Key-Pair-Id": KEY_PAIR_ID
}

# In a Flask/FastAPI response:
# for name, value in cookies.items():
#     response.set_cookie(name, value, secure=True, httponly=True, samesite="Strict")
print(cookies)
Set-Cookie best practices: Always set Secure, HttpOnly, and SameSite=Strict on the cookie response. The cookie domain must match your CloudFront alternate domain (CNAME), not the *.cloudfront.net domain.

5. Key Groups and Public Key Rotation

CloudFront key groups replace the legacy "trusted signers" model. A key group holds one or more public keys. CloudFront validates signed URLs/cookies against any key in the group, enabling zero-downtime key rotation.

Creating a Key Pair and Uploading the Public Key

# 1. Generate RSA-2048 key pair
openssl genrsa -out private_key.pem 2048
openssl rsa -pubout -in private_key.pem -out public_key.pem

# 2. Upload the public key to CloudFront
aws cloudfront create-public-key \
  --public-key-config '{
    "CallerReference": "cf-key-2026-06",
    "Name": "techoral-signing-key-2026-06",
    "EncodedKey": "'"$(cat public_key.pem)"'",
    "Comment": "Signing key — rotate every 90 days"
  }'
# Returns: PublicKey.Id (e.g. APKAEIBAERJR2EXAMPLE)

# 3. Create a key group containing that public key
aws cloudfront create-key-group \
  --key-group-config '{
    "Name": "techoral-key-group",
    "Items": ["APKAEIBAERJR2EXAMPLE"],
    "Comment": "Production signing key group"
  }'

Zero-Downtime Rotation

  1. Generate a new key pair.
  2. Upload the new public key — get its ID.
  3. Add the new key ID to the existing key group (group now contains both keys).
  4. Update your signing service to use the new private key.
  5. Wait for all old signed URLs/cookies to expire.
  6. Remove the old key from the key group and delete it.
CloudFront allows up to 5 public keys per key group. Use this headroom to support overlapping rotation windows without invalidating active user sessions.

6. WAF Integration: WebACL, Rate Limits & Geo-Blocking

AWS WAF is attached to a CloudFront distribution at the WebACL level. WAF rules evaluate every HTTP request at the edge before it is forwarded to your origin or served from cache.

Attaching a WebACL to CloudFront (Terraform snippet)

resource "aws_wafv2_web_acl" "cloudfront_acl" {
  name  = "techoral-cf-acl"
  scope = "CLOUDFRONT"   # Must be CLOUDFRONT for edge deployment
  # WAFv2 resources for CloudFront MUST be created in us-east-1
  provider = aws.us_east_1

  default_action {
    allow {}
  }

  # AWS Managed Rule: Core Rule Set (protects against OWASP Top 10)
  rule {
    name     = "AWSManagedRulesCRS"
    priority = 10
    override_action { none {} }
    statement {
      managed_rule_group_statement {
        vendor_name = "AWS"
        name        = "AWSManagedRulesCommonRuleSet"
      }
    }
    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "crs"
      sampled_requests_enabled   = true
    }
  }

  # Rate-limiting rule: block IPs sending >1000 req/5min
  rule {
    name     = "RateLimit1000"
    priority = 20
    action { block {} }
    statement {
      rate_based_statement {
        limit              = 1000
        aggregate_key_type = "IP"
      }
    }
    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "rate-limit"
      sampled_requests_enabled   = true
    }
  }

  visibility_config {
    cloudwatch_metrics_enabled = true
    metric_name                = "cf-acl"
    sampled_requests_enabled   = true
  }
}

resource "aws_cloudfront_distribution" "main" {
  # ... other config ...
  web_acl_id = aws_wafv2_web_acl.cloudfront_acl.arn
}

Recommended Managed Rule Groups

  • AWSManagedRulesCommonRuleSet — OWASP Top 10 (SQLi, XSS, LFI, RFI)
  • AWSManagedRulesKnownBadInputsRuleSet — Log4Shell, Spring4Shell, SSRF probes
  • AWSManagedRulesBotControlRuleSet — Targeted bot management (scrapers, credential stuffers)
  • AWSManagedRulesAmazonIpReputationList — Blocks IPs flagged as malicious by AWS Threat Intel
Cost note: WAF Bot Control (targeted level) costs ~$10/month plus $1 per million requests. Enable it on login and checkout endpoints first. Use CloudFront Functions for lightweight bot checks on static content to save WAF WCU capacity.

7. Security Headers via Response Headers Policy

CloudFront Response Headers Policies inject HTTP security headers into every response without touching your origin code. This is the cleanest way to enforce browser security policies across all content.

Creating a Response Headers Policy via AWS CLI

aws cloudfront create-response-headers-policy \
  --response-headers-policy-config '{
    "Name": "techoral-security-headers",
    "Comment": "HSTS, CSP, X-Frame-Options, Referrer-Policy",
    "SecurityHeadersConfig": {
      "StrictTransportSecurity": {
        "Override": true,
        "AccessControlMaxAgeSec": 31536000,
        "IncludeSubdomains": true,
        "Preload": true
      },
      "ContentTypeOptions": {
        "Override": true
      },
      "FrameOptions": {
        "FrameOption": "DENY",
        "Override": true
      },
      "XSSProtection": {
        "Protection": true,
        "ModeBlock": true,
        "Override": true
      },
      "ReferrerPolicy": {
        "ReferrerPolicy": "strict-origin-when-cross-origin",
        "Override": true
      }
    },
    "CustomHeadersConfig": {
      "Quantity": 1,
      "Items": [
        {
          "Header": "Content-Security-Policy",
          "Value": "default-src '"'"'self'"'"'; script-src '"'"'self'"'"' https://cdn.example.com; object-src '"'"'none'"'"'",
          "Override": true
        }
      ]
    }
  }'

After creating the policy, attach it to the default cache behaviour (or a specific path pattern) in your distribution config via ResponseHeadersPolicyId.

Header Reference

HeaderRecommended ValuePurpose
Strict-Transport-Securitymax-age=31536000; includeSubDomains; preloadForces HTTPS for 1 year
X-Content-Type-OptionsnosniffPrevents MIME-type sniffing
X-Frame-OptionsDENYPrevents clickjacking
Referrer-Policystrict-origin-when-cross-originLimits referrer leakage
Content-Security-PolicyCustom per appRestricts resource origins
Permissions-Policycamera=(), microphone=()Restricts browser APIs

8. Field-Level Encryption

Field-level encryption (FLE) encrypts specific form fields (e.g., credit card numbers, social security numbers) at the CloudFront edge using an asymmetric key pair. The encrypted value travels through the entire AWS infrastructure — including CloudFront edge nodes, ALBs, and application servers — and is only decryptable by the component that holds the private key.

How FLE Works

  1. You upload a public key to CloudFront and create a field-level encryption profile.
  2. CloudFront intercepts POST requests matching a specified path pattern.
  3. Specified fields are encrypted with RSA-OAEP using your public key.
  4. The encrypted body is forwarded to your origin.
  5. Only your private key holder (e.g., a secure microservice) can decrypt the field.

Creating an FLE Profile (AWS CLI)

# 1. Create a public key for FLE (separate from signing keys)
aws cloudfront create-public-key \
  --public-key-config '{
    "CallerReference": "fle-key-2026",
    "Name": "fle-public-key",
    "EncodedKey": "'"$(cat fle_public_key.pem)"'"
  }'

# 2. Create an encryption profile — specify which fields to encrypt
aws cloudfront create-field-level-encryption-profile \
  --field-level-encryption-profile-config '{
    "Name": "pci-fields-profile",
    "CallerReference": "fle-profile-2026",
    "EncryptionEntities": {
      "Quantity": 1,
      "Items": [{
        "PublicKeyId": "APKA...FLE_KEY_ID",
        "ProviderId": "pci-provider",
        "FieldPatterns": {
          "Quantity": 2,
          "Items": ["card_number", "cvv"]
        }
      }]
    }
  }'

# 3. Create an FLE config and attach the profile
aws cloudfront create-field-level-encryption-config \
  --field-level-encryption-config '{
    "CallerReference": "fle-config-2026",
    "QueryArgProfileConfig": { "ForwardWhenQueryArgProfileIsUnknown": false, "QueryArgProfiles": {"Quantity": 0, "Items": []} },
    "ContentTypeProfileConfig": {
      "ForwardWhenContentTypeIsUnknown": false,
      "ContentTypeProfiles": {
        "Quantity": 1,
        "Items": [{"Format": "URLEncoded", "ProfileId": "FLE_PROFILE_ID", "ContentType": "application/x-www-form-urlencoded"}]
      }
    }
  }'
FLE limitation: FLE only works with POST requests using application/x-www-form-urlencoded or multipart/form-data content types. JSON-body APIs require client-side encryption before submission (e.g., using the AWS Encryption SDK).

9. Geo-Restriction: Allowlist & Blocklist

CloudFront geo-restriction lets you control which countries can access your distribution based on IP geolocation. You can either allowlist specific countries (block all others) or blocklist specific countries (allow all others).

For more granular geo-based routing or custom error responses by country, use Lambda@Edge with CloudFront-Viewer-Country header instead.

Enable Geo-Restriction via AWS CLI

# Allowlist: only USA, Canada, UK can access
aws cloudfront update-distribution \
  --id EDFDVBD6EXAMPLE \
  --if-match ETVPDKIKX0DER \
  --distribution-config '{
    "Restrictions": {
      "GeoRestriction": {
        "RestrictionType": "whitelist",
        "Quantity": 3,
        "Items": ["US", "CA", "GB"]
      }
    }
  }'

# Blocklist: block specific high-risk countries
aws cloudfront update-distribution \
  --id EDFDVBD6EXAMPLE \
  --if-match ETVPDKIKX0DER \
  --distribution-config '{
    "Restrictions": {
      "GeoRestriction": {
        "RestrictionType": "blacklist",
        "Quantity": 2,
        "Items": ["KP", "CU"]
      }
    }
  }'

Custom 403 Error Page for Blocked Regions

Instead of showing a generic CloudFront 403, configure a custom error response that serves a localised "not available in your region" page hosted in your S3 origin:

{
  "CustomErrorResponses": {
    "Quantity": 1,
    "Items": [{
      "ErrorCode": 403,
      "ResponsePagePath": "/errors/geo-blocked.html",
      "ResponseCode": "403",
      "ErrorCachingMinTTL": 300
    }]
  }
}
ISO 3166-1 alpha-2 country codes are used. CloudFront geo-restriction is based on MaxMind's GeoLite2 database. For compliance-grade geo-enforcement (e.g., OFAC sanctions), combine CloudFront geo-restriction with WAF geo-match rules and application-layer checks.

10. HTTPS Enforcement & TLS Configuration

A fully secured CloudFront distribution should enforce HTTPS at every layer: viewer protocol, origin protocol, and minimum TLS version. Use your own SSL certificate via AWS Certificate Manager (ACM) for custom domains.

Viewer Protocol Policy

Set ViewerProtocolPolicy to redirect-to-https on all cache behaviours:

{
  "DefaultCacheBehavior": {
    "ViewerProtocolPolicy": "redirect-to-https",
    "MinTTL": 0,
    "AllowedMethods": {
      "Quantity": 2,
      "Items": ["GET", "HEAD"]
    }
  }
}

Origin Protocol Policy

For custom (non-S3) origins, enforce HTTPS between CloudFront and your origin:

{
  "CustomOriginConfig": {
    "HTTPSPort": 443,
    "OriginProtocolPolicy": "https-only",
    "OriginSSLProtocols": {
      "Quantity": 2,
      "Items": ["TLSv1.2", "TLSv1.3"]
    }
  }
}

Minimum TLS Version for Viewers

{
  "ViewerCertificate": {
    "ACMCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/abc12345",
    "SSLSupportMethod": "sni-only",
    "MinimumProtocolVersion": "TLSv1.2_2021"
  }
}
Security PolicyTLS VersionsCiphersUse Case
TLSv1.2_20211.2, 1.3Modern onlyRecommended for new distributions
TLSv1.2_20191.2, 1.3Broad modernGood balance
TLSv1_20161.0–1.3Includes weak ciphersLegacy support only
ACM certificates for CloudFront must be requested in the us-east-1 region regardless of where your origin is. CloudFront is a global service that reads certificates only from us-east-1 ACM. See Route 53 DNS for setting up CNAME validation.

11. Monitoring: Access Logs & Real-Time Logs

Security without visibility is incomplete. CloudFront offers two logging mechanisms: standard access logs (batch delivery to S3) and real-time logs (streaming via Kinesis Data Streams).

Standard Access Logs to S3

{
  "Logging": {
    "Enabled": true,
    "IncludeCookies": false,
    "Bucket": "my-cf-logs.s3.amazonaws.com",
    "Prefix": "cloudfront/"
  }
}

Standard logs are delivered within minutes and contain fields including viewer IP, HTTP method, URI, status code, user agent, referrer, and edge location. Use CloudWatch Logs Insights or Athena to query them.

Real-Time Logs via Kinesis Data Streams

Real-time logs deliver within seconds at configurable sampling rates (1–100%). Ideal for live threat detection and SIEM integration.

# Create a Kinesis stream for real-time CF logs
aws kinesis create-stream \
  --stream-name cloudfront-realtime-logs \
  --shard-count 2

# Create the real-time log config
aws cloudfront create-realtime-log-config \
  --end-points '[{
    "StreamType": "Kinesis",
    "KinesisStreamConfig": {
      "RoleARN": "arn:aws:iam::123456789012:role/CloudFrontRealtimeLogRole",
      "StreamARN": "arn:aws:kinesis:us-east-1:123456789012:stream/cloudfront-realtime-logs"
    }
  }]' \
  --fields '["timestamp","c-ip","cs-method","cs-uri-stem","sc-status","cs(User-Agent)","x-edge-location","time-taken"]' \
  --name cf-realtime-config \
  --sampling-rate 100

Key Metrics to Alert On

  • 5xxErrorRate > 1% — origin health degradation or WAF misconfiguration blocking legitimate traffic.
  • 4xxErrorRate spike — potential scanning or credential-stuffing attack; cross-reference with WAF blocked requests.
  • Requests by edge location — unexpected surges from unusual regions may indicate DDoS or botnet activity.
  • WAF BlockedRequests — baseline this metric; sudden drops can mean attackers switched techniques.
  • OriginLatency — latency increases under attack load may indicate origin overwhelm despite WAF.
Set up CloudWatch alarms on AWS/CloudFront namespace metrics with SNS notifications to your security team. For production distributions, enable AWS Shield Advanced to get DDoS-specific dashboards and 24/7 Shield Response Team (SRT) access.

Continue Learning