AWS CloudFront Security: OAC, Signed URLs and WAF Integration (2026)
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
- CloudFront Security Overview & Threat Model
- Origin Access Control (OAC) vs OAI — Migration Guide
- Signed URLs: Use Cases & Python Implementation
- Signed Cookies for Multi-File Access
- Key Groups and Public Key Rotation
- WAF Integration: WebACL, Rate Limits & Geo-Blocking
- Security Headers via Response Headers Policy
- Field-Level Encryption
- Geo-Restriction: Allowlist & Blocklist
- HTTPS Enforcement & TLS Configuration
- 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.
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
| Feature | OAI | OAC |
|---|---|---|
| SSE-KMS support | No | Yes |
| Signing protocol | Custom (deprecated) | AWS SigV4 |
| MediaStore / MediaPackage origins | No | Yes |
| Lambda Function URL origins | No | Yes |
| Recommended | No (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: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)
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)
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
- Generate a new key pair.
- Upload the new public key — get its ID.
- Add the new key ID to the existing key group (group now contains both keys).
- Update your signing service to use the new private key.
- Wait for all old signed URLs/cookies to expire.
- Remove the old key from the key group and delete it.
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
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
| Header | Recommended Value | Purpose |
|---|---|---|
| Strict-Transport-Security | max-age=31536000; includeSubDomains; preload | Forces HTTPS for 1 year |
| X-Content-Type-Options | nosniff | Prevents MIME-type sniffing |
| X-Frame-Options | DENY | Prevents clickjacking |
| Referrer-Policy | strict-origin-when-cross-origin | Limits referrer leakage |
| Content-Security-Policy | Custom per app | Restricts resource origins |
| Permissions-Policy | camera=(), 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
- You upload a public key to CloudFront and create a field-level encryption profile.
- CloudFront intercepts POST requests matching a specified path pattern.
- Specified fields are encrypted with RSA-OAEP using your public key.
- The encrypted body is forwarded to your origin.
- 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"}]
}
}
}'
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
}]
}
}
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 Policy | TLS Versions | Ciphers | Use Case |
|---|---|---|---|
| TLSv1.2_2021 | 1.2, 1.3 | Modern only | Recommended for new distributions |
| TLSv1.2_2019 | 1.2, 1.3 | Broad modern | Good balance |
| TLSv1_2016 | 1.0–1.3 | Includes weak ciphers | Legacy support only |
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.
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
- AWS CloudFront Complete Guide — Performance & Caching
- AWS WAF & Shield — DDoS Protection Deep Dive
- AWS S3 Tutorial — Buckets, Policies & Versioning
- Lambda@Edge — Edge Computing with CloudFront
- AWS Security Best Practices 2026
- AWS IAM Roles & Policies — Least Privilege Guide
- AWS CloudWatch Monitoring & Alerting
- AWS Route 53 DNS — Custom Domains & Routing