AWS Builder Center

Serving WordPress at Scale with Amazon CloudFront and AWS WAF

A production-ready guide to solving WordPress's cookie-based caching challenges with CloudFront Functions, configuring AWS WAF to protect wp-admin without false positives, and using tag-based cache invalidation for instant content updates.

Serving WordPress at Scale with Amazon CloudFront and AWS WAF

Introduction

WordPress powers over 43% of all websites globally — from media publications and university portals to e-commerce stores and financial services marketing sites. Despite its origins as "blog software," WordPress is the dominant enterprise CMS because it lets non-technical teams publish content independently, supports 100+ languages, and has an ecosystem of 65,000+ plugins.
For CDN and WAF engineers, WordPress presents unique challenges: its cookie-based session model breaks naive caching strategies, its admin interface triggers WAF false positives, and its attack surface (xmlrpc.php, wp-login.php) requires targeted protection. This guide provides production-ready configurations for CloudFront and AWS WAF to serve WordPress sites with high cache hit rates, low latency, and strong security.

1. WordPress Architecture Basics (for CDN/WAF Engineers)

What WordPress Sites Typically Look Like

LayerRole
Marketing pages, blog posts, landing pagesWordPress (PHP + MySQL)
Client portals, APIs, transactionsSeparate backend (not WordPress)
Static assets (images, CSS, JS)Served from /wp-content/
Admin interface/wp-admin/ and /wp-login.php
WordPress handles the public-facing content layer. Business-critical systems (payments, user data, APIs) typically run on separate infrastructure. This means aggressive caching of the WordPress layer is both safe and high-value.

The Cookie Problem

WordPress sets several cookies that interfere with CDN caching:
Cookie PatternSet WhenImpact
wordpress_logged_in_*User logs inIndicates authenticated session
wp-settings-*User accesses adminStores admin preferences
comment_author_*User posts a commentPre-fills comment form
Plugin cookies (analytics, consent, A/B test)VariesMay or may not affect page output
If these cookies are included in the cache key, each unique cookie combination creates a separate cached copy — effectively reducing hit rate to zero.
Business risks of incorrect cookie handling:
RiskCauseImpact
Cache hit rate → 0%Cookie values included in cache keyOrigin overload, no CDN benefit
Serving personalized content to wrong userStripping cookies but origin renders different HTML per cookiePrivacy/compliance violations, broken A/B tests
Mitigation strategies:
  1. Client-side rendering for personalization (preferred): Keep HTML generic and cacheable. Inject personalized elements (consent banners, A/B variants) via client-side JavaScript.
  2. Whitelist specific cookies in cache key: Only if a plugin renders different server-side HTML based on a cookie with a small number of variants (e.g., currency=USD|EUR|GBP).
  3. Bypass cache for affected paths: For pages with heavy server-side personalization (e.g., WooCommerce cart/checkout).
Most WordPress marketing sites handle personalization (consent banners, chat widgets, A/B tests) via client-side JS injected after page load, which doesn't affect server-rendered HTML. Therefore stripping all cookies and full-page caching for anonymous visitors is safe.

2. CloudFront Configuration for WordPress

2.1 Cache Behavior Design

CloudFront requires multiple ordered cache behaviors to properly serve WordPress:
PriorityPath PatternCache StrategyCookiesTTL
1/wp-admin/*No cache (bypass)All0
2/wp-login.phpNo cacheAll0
3/wp-cron.phpNo cacheAll0
4/wp-json/*No cacheAll (Gutenberg uses authenticated REST API)0
5/wp-content/uploads/*Long TTLNone86400s
6/wp-includes/*Long TTLNone31536000s
7*.css, *.js, imagesLong TTLNone31536000s
8/* (default)Short TTLNone (stripped by function)300s

2.2 Cache Policy

For anonymous page caching (default behavior):
The following are configuration field descriptions (pseudocode, not a directly deployable template):
1
2
3
4
5
6
7
8
WordPressCachePolicy:
CookieBehavior: none # Exclude all cookies from cache key
HeaderBehavior: none
QueryStringBehavior: whitelist
QueryStrings: [p, page_id, preview, preview_nonce]
MinTTL: 0 # Must be 0, otherwise origin's Cache-Control: no-store is ignored
DefaultTTL: 300 # 5 minutes
MaxTTL: 86400 # 1 day
For admin/bypass paths:
1
2
3
4
5
WordPressBypassPolicy:
CookieBehavior: all
QueryStringBehavior: all
DefaultTTL: 0
MaxTTL: 0
Preview handling: WordPress preview URLs use ?preview=true&p=123. Including preview in the query string whitelist ensures previews generate a different cache key. Combined with WordPress returning Cache-Control: no-store for preview pages (when auth cookies are present), previews are never cached.

2.3 Origin Request Policy

Two separate ORPs are needed:
For dynamic pages (default behavior) — must forward cookies so WordPress can detect auth state:
1
2
3
4
5
WordPressPageOriginRequestPolicy:
HeaderBehavior: whitelist
Headers: [Host, X-Forwarded-For, CloudFront-Forwarded-Proto]
CookieBehavior: all # Origin needs cookies to detect logged-in users
QueryStringBehavior: all
For static assets — no cookies needed:
1
2
3
4
5
WordPressStaticOriginRequestPolicy:
HeaderBehavior: whitelist
Headers: [Host]
CookieBehavior: none
QueryStringBehavior: none

2.4 CloudFront Function: Cookie Stripping (Viewer-Request)

This is the most critical optimization — attach to viewer-request on the default cache behavior:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function handler(event) {
var request = event.request;
var cookies = request.cookies;

// Detect logged-in WordPress user or commenter
for (var name in cookies) {
if (name.startsWith('wordpress_logged_in') ||
name.startsWith('wordpress_sec') ||
name.startsWith('wp-settings') ||
name.startsWith('comment_author')) {
// Pass all cookies through — origin will respond with
// Cache-Control: no-store (WordPress does this natively
// when it detects an authenticated session).
return request;
}
}

// Anonymous visitor: strip ALL cookies
request.cookies = {};
return request;
}
How the bypass works without a separate behavior: The cache policy (CookieBehavior: none) excludes cookies from the cache key regardless. But by forwarding cookies to origin (via ORP), WordPress detects the auth session server-side and responds with Cache-Control: no-store. CloudFront respects this and doesn't cache the response.
Alternative: Cache-key-based bypass
For origins that don't reliably set Cache-Control: no-store (e.g., some managed hosting platforms), force a guaranteed cache miss by injecting a unique header value. The prerequisite is that the Cache Policy's HeaderBehavior must be set to whitelist and include x-cache-bypass, making it part of the cache key — this way each request with a bypass cookie generates a unique cache key, guaranteeing a cache miss and forward to origin.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var BYPASS_COOKIES = ['wordpress_logged_in', 'wordpress_sec', 'wp-settings',
'comment_author', 'woocommerce_cart_hash', 'woocommerce_items_in_cart'];

function shouldBypass(cookies) {
for (var name in cookies) {
for (var i = 0; i < BYPASS_COOKIES.length; i++) {
if (name.startsWith(BYPASS_COOKIES[i])) return true;
}
}
return false;
}

if (shouldBypass(request.cookies)) {
// Inject unique value into a header whitelisted in Cache Policy
request.headers['x-cache-bypass'] = { value: '' + Math.random() };
}
Math.random() in CloudFront Functions uses OpenBSD arc4random — cryptographically independent per invocation. More reliable than Date.now() which produces identical values for concurrent requests within the same millisecond.

2.5 Cache Invalidation with Cache Tags

CloudFront's tag-based invalidation (launched April 2026) eliminates the need to predict which URL paths to purge. Tag cached objects semantically and invalidate by group.
How it works:
  1. Enable CacheTagConfig on the distribution with a header name (e.g., x-cache-tag)
  2. Origin returns tags in that header: x-cache-tag: post-type:post, category:news, author:jane
  3. On content update, invalidate by tag:
1
2
3
aws cloudfront create-invalidation \
--distribution-id EXXXXXX \
--paths "#post:my-post-slug" "#category:news" "#page:home"
Limits: max 50 tags per object, 256 chars per tag, case-insensitive. Each #tag counts as one path toward the 1,000 free monthly invalidations.
If origin can't set the header natively, use Lambda@Edge origin-response (runs only on cache misses):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export const handler = async (event) => {
const response = event.Records[0].cf.response;
const uri = event.Records[0].cf.request.uri;
const tags = [];

if (uri === '/') tags.push('page:home');
if (/^\/\d{4}\/\d{2}\/[^/]+\/?$/.test(uri)) {
const slug = uri.replace(/^\/\d{4}\/\d{2}\//, '').replace(/\/$/, '');
tags.push('post-type:post', `post:${slug}`);
}
const catMatch = uri.match(/^\/category\/([^/]+)\/?/);
if (catMatch) tags.push(`category:${catMatch[1]}`, 'archive:category');

if (tags.length) {
response.headers['x-cache-tag'] = [{ key: 'x-cache-tag', value: tags.join(', ') }];
}
return response;
};
Limitation: URL-pattern-based tagging can only infer tags from URL structure. A post at /2024/06/my-post/ may belong to multiple categories not visible in the URL. For complete tag coverage, the origin should output the header natively via a WordPress MU-plugin.
Cost: Lambda@Edge origin-response at 10% miss rate on 10M page views ≈ $0.63/month.
Simpler alternative: Set default TTL to 60–300s and skip invalidation entirely. For marketing sites where content freshness within minutes is acceptable, this is the lowest-complexity option.

2.6 WooCommerce Considerations

WooCommerce sites need additional bypass paths for session-dependent pages:
PathReasonImplementation
/cart/*Per-user cart contentsCache behavior (path pattern)
/checkout/*Payment flow, CSRF tokensCache behavior (path pattern)
/my-account/*Authenticated dashboardCache behavior (path pattern)
?add-to-cart=*Cart modificationCFF logic (query string check)
?add-to-cart=* cannot be handled via cache behavior because CloudFront path patterns only match the URI path portion, not query strings after ?. Handle in CFF instead:
1
2
3
4
5
6
// Inside CFF viewer-request handler:
var qs = request.querystring;
if (qs['add-to-cart'] || qs['remove_item'] || qs['wc-ajax']) {
request.headers['x-cache-bypass'] = { value: '' + Math.random() };
return request;
}

2.7 wp-cron.php

WordPress scheduled tasks (scheduled posts, plugin cron jobs, etc.) rely on wp-cron.php being passively triggered: every time a visitor loads a page, WordPress piggybacks a request to wp-cron.php. This mechanism has two inherent problems:
  • No visitors = no trigger: During low-traffic periods (e.g., late night), scheduled tasks won't execute
  • High concurrency = duplicate triggers: Many simultaneous visitors may trigger cron multiple times, wasting resources
While section 2.1 already ensures CloudFront doesn't cache wp-cron.php (TTL=0), the above issues are unrelated to CDN. The recommended approach is to disable passive triggering and use system cron for controlled, predictable execution:
1
2
// wp-config.php
define('DISABLE_WP_CRON', true);
1
2
# System cron — hits origin directly, bypasses CloudFront
*/5 * * * * curl -s https://origin-server.example.com/wp-cron.php > /dev/null

3. AWS WAF Configuration for WordPress

3.1 Recommended Managed Rule Groups

Rule GroupWCUPurpose
AWSManagedRulesAntiDDoSRuleSet50Application-layer (L7) DDoS auto-protection, ML-based baseline anomaly detection, sub-second response
AWSManagedRulesAmazonIpReputationList25Known bad IPs, botnets
AWSManagedRulesCommonRuleSet (CRS)700OWASP Top 10: XSS, SQLi, LFI, size limits
AWSManagedRulesAdminProtectionRuleSet100Admin path access control
AWSManagedRulesKnownBadInputsRuleSet200Log4j, Java RCE, known exploit patterns
AWSManagedRulesSQLiRuleSet200SQL injection
AWSManagedRulesPHPRuleSet100PHP-specific injections
AWSManagedRulesWordPressRuleSet100WordPress-specific exploits
AWSManagedRulesLinuxRuleSet200Linux LFI attacks
Total~1,675of 5,000 WCU limit

3.2 WordPress-Specific Rule Group

AWSManagedRulesWordPressRuleSet (WCU: 100) protects against:
RuleProtection
WordPressExploitablePaths_URIPATHBlocks xmlrpc.php and other exploitable files
WordPressExploitableCommands_QUERYSTRINGBlocks high-risk query commands (e.g., do-reset-wordpress)
Use in conjunction with SQLi and PHP rule groups for comprehensive coverage.

3.2b AdminProtectionRuleSet — Handle with Care

AWSManagedRulesAdminProtectionRuleSet (WCU: 100) contains a rule AdminProtection_URIPATH that blocks access to admin paths including /wp-admin/. If enabled without exclusions, legitimate WordPress administrators will be blocked from accessing the dashboard.
Options:
  1. Override to Count + Label + Custom Rule (recommended, consistent with Section 3.4 pattern):
    • Set AdminProtection_URIPATH to Count
    • Add custom rule: match label awswaf:managed:aws:admin-protection:AdminProtection_URIPATH + NOT(source IP in admin IP set) → Block
    • Result: admin path blocked for everyone except known admin IPs
  2. Remove the rule group entirely: If you already have geo-blocking on /wp-admin (Section 3.3) and rate limiting on /wp-login.php, the AdminProtection rule group may be redundant. Evaluate whether the additional coverage justifies the operational overhead.
  3. Scope-down the rule group to exclude /wp-admin/admin-ajax.php — this endpoint is called by WordPress frontend features (comment forms, AJAX search) and should not be blocked for public visitors.

3.3 Custom Rules

The following JSON fragments are single rule definitions to be placed inside the Web ACL Rules array — they cannot be pasted directly at the top level of the Web ACL JSON editor.
Rate limit wp-login.php (brute force protection):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"Name": "RateLimitWpLogin",
"Priority": 5,
"Statement": {
"RateBasedStatement": {
"Limit": 100,
"EvaluationWindowSec": 300,
"AggregateKeyType": "IP",
"ScopeDownStatement": {
"ByteMatchStatement": {
"SearchString": "/wp-login.php",
"FieldToMatch": { "UriPath": {} },
"TextTransformations": [{"Priority": 0, "Type": "LOWERCASE"}],
"PositionalConstraint": "STARTS_WITH"
}
}
}
},
"Action": { "Block": {} }
}
Geo-restrict wp-admin: Block /wp-admin from countries where admins don't operate.
Global rate limit (HTTP flood protection):
1
2
3
4
5
6
7
8
9
10
11
12
{
"Name": "RateLimitGlobal",
"Priority": 3,
"Statement": {
"RateBasedStatement": {
"Limit": 2000,
"EvaluationWindowSec": 300,
"AggregateKeyType": "IP"
}
},
"Action": { "Block": {} },
}
Blocks any single IP exceeding 2000 requests in 5 minutes. Adjust the threshold based on your site's normal traffic patterns.
Block wp-config.php: Direct access should always be blocked (contains database credentials).

3.4 Handling False Positives: Count + Label + Custom Rule Pattern

The biggest WordPress WAF challenge: admins saving posts with HTML/JS content triggers CRS CrossSiteScripting_BODY and SizeRestrictions_BODY false positives.
The recommended pattern for all managed rule groups:
  1. Override problematic rules to Count (they still inspect and apply labels, but don't block)
  2. Add a custom rule after the AMR that matches the label + a NOT condition to exclude legitimate traffic
This preserves detection intelligence while eliminating false positives for known-good paths.
Step 1: Override to Count
1
2
3
4
5
6
7
8
"ManagedRuleGroupStatement": {
"VendorName": "AWS",
"Name": "AWSManagedRulesCommonRuleSet",
"RuleActionOverrides": [
{ "Name": "CrossSiteScripting_BODY", "ActionToUse": { "Count": {} } },
{ "Name": "SizeRestrictions_BODY", "ActionToUse": { "Count": {} } }
]
}
Step 2: Custom rule — block XSS in body except from wp-admin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
"Name": "BlockXSSBodyExceptAdmin",
"Priority": 35,
"Statement": {
"AndStatement": {
"Statements": [
{
"LabelMatchStatement": {
"Scope": "LABEL",
"Key": "awswaf:managed:aws:core-rule-set:CrossSiteScripting_Body"
}
},
{
"NotStatement": {
"Statement": {
"ByteMatchStatement": {
"SearchString": "/wp-admin",
"FieldToMatch": { "UriPath": {} },
"TextTransformations": [{"Priority": 0, "Type": "LOWERCASE"}],
"PositionalConstraint": "STARTS_WITH"
}
}
}
}
]
}
},
"Action": { "Block": {} }
}
This blocks XSS in body for all paths except /wp-admin/* where editors legitimately POST HTML content.
For SizeRestrictions_BODY: Consider leaving as Count permanently. WordPress posts, media uploads, and product descriptions routinely exceed default size limits, and maintaining size-based exceptions across endpoints has high operational cost.
Why this is better than an Allow rule: A terminating Allow rule on admin paths bypasses all downstream rules for that traffic. The Count + Label + Custom Rule approach only relaxes the specific rule causing problems while all other protections remain active.

3.5 Rule Priority Order

PriorityRuleWCU
1Block wp-config.php (custom)10
2Anti-DDoS (AMR) — needs to see all traffic early to build complete baseline50
3Global rate limit 2000 req/5min per IP (custom)2
5Rate limit wp-login.php 100 req/5min per IP (custom)12
6Geo-block wp-admin (custom)~12
10IP Reputation List (AMR)25
30Common Rule Set — CRS (AMR, with Count overrides)700
35Block XSS Body except admin (custom, matches CRS label)~12
40Admin Protection (AMR)100
50Known Bad Inputs (AMR)200
60SQLi Rule Set (AMR)200
70PHP Rule Set (AMR)100
80WordPress Rule Set (AMR)100
90Linux Rule Set (AMR)200
Total~1,723 / 5,000
Principles:
  • Custom blocks first (cheap WCU, high confidence)
  • IP reputation before expensive inspection rules
  • AMRs with Count overrides where needed, followed immediately by custom rules matching their labels
  • Prefer Count + Label + Custom Rule over terminating Allow rules

3.6 CloudFront-Specific WAF Notes

  • Web ACL must be in us-east-1 (global scope) for CloudFront association
  • Default body inspection limit: 16 KB (increase to 64 KB for large WordPress post content)
  • Rate-based rules use IP aggregate — CloudFront forwards true client IP

4. Summary

ChallengeSolution
WordPress cookies fragment cacheCloudFront Function strips cookies for anonymous visitors; logged-in users bypass via origin Cache-Control
Cache invalidation on publishTag-based invalidation (semantic, precise) or short TTL (simple)
xmlrpc.php / wp-login.php attacksAWSManagedRulesWordPressRuleSet + rate limiting custom rule
Admin editing triggers XSS false positivesCount + Label + Custom Rule: block everywhere except /wp-admin
WooCommerce session pagesSeparate bypass cache behaviors + CFF query string detection
wp-cron breaks under CDNDisable WP-Cron, use system cron hitting origin directly
This guide provides configuration concepts and rule fragments, not directly deployable templates. Adjust parameters based on your site's traffic patterns, admin IP ranges, and business requirements. Test in Count mode before switching to Block.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article