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
| Layer | Role |
|---|---|
| Marketing pages, blog posts, landing pages | WordPress (PHP + MySQL) |
| Client portals, APIs, transactions | Separate 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 Pattern | Set When | Impact |
|---|---|---|
wordpress_logged_in_* | User logs in | Indicates authenticated session |
wp-settings-* | User accesses admin | Stores admin preferences |
comment_author_* | User posts a comment | Pre-fills comment form |
| Plugin cookies (analytics, consent, A/B test) | Varies | May 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:
| Risk | Cause | Impact |
|---|---|---|
| Cache hit rate → 0% | Cookie values included in cache key | Origin overload, no CDN benefit |
| Serving personalized content to wrong user | Stripping cookies but origin renders different HTML per cookie | Privacy/compliance violations, broken A/B tests |
Mitigation strategies:
- Client-side rendering for personalization (preferred): Keep HTML generic and cacheable. Inject personalized elements (consent banners, A/B variants) via client-side JavaScript.
- 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). - 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:
| Priority | Path Pattern | Cache Strategy | Cookies | TTL |
|---|---|---|---|---|
| 1 | /wp-admin/* | No cache (bypass) | All | 0 |
| 2 | /wp-login.php | No cache | All | 0 |
| 3 | /wp-cron.php | No cache | All | 0 |
| 4 | /wp-json/* | No cache | All (Gutenberg uses authenticated REST API) | 0 |
| 5 | /wp-content/uploads/* | Long TTL | None | 86400s |
| 6 | /wp-includes/* | Long TTL | None | 31536000s |
| 7 | *.css, *.js, images | Long TTL | None | 31536000s |
| 8 | /* (default) | Short TTL | None (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 dayFor admin/bypass paths:
1
2
3
4
5
WordPressBypassPolicy:
CookieBehavior: all
QueryStringBehavior: all
DefaultTTL: 0
MaxTTL: 0Preview handling: WordPress preview URLs use?preview=true&p=123. Includingpreviewin the query string whitelist ensures previews generate a different cache key. Combined with WordPress returningCache-Control: no-storefor 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: allFor static assets — no cookies needed:
1
2
3
4
5
WordPressStaticOriginRequestPolicy:
HeaderBehavior: whitelist
Headers: [Host]
CookieBehavior: none
QueryStringBehavior: none2.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 thanDate.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:
- Enable
CacheTagConfigon the distribution with a header name (e.g.,x-cache-tag) - Origin returns tags in that header:
x-cache-tag: post-type:post, category:news, author:jane - 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:
| Path | Reason | Implementation |
|---|---|---|
/cart/* | Per-user cart contents | Cache behavior (path pattern) |
/checkout/* | Payment flow, CSRF tokens | Cache behavior (path pattern) |
/my-account/* | Authenticated dashboard | Cache behavior (path pattern) |
?add-to-cart=* | Cart modification | CFF 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/null3. AWS WAF Configuration for WordPress
3.1 Recommended Managed Rule Groups
| Rule Group | WCU | Purpose |
|---|---|---|
AWSManagedRulesAntiDDoSRuleSet | 50 | Application-layer (L7) DDoS auto-protection, ML-based baseline anomaly detection, sub-second response |
AWSManagedRulesAmazonIpReputationList | 25 | Known bad IPs, botnets |
AWSManagedRulesCommonRuleSet (CRS) | 700 | OWASP Top 10: XSS, SQLi, LFI, size limits |
AWSManagedRulesAdminProtectionRuleSet | 100 | Admin path access control |
AWSManagedRulesKnownBadInputsRuleSet | 200 | Log4j, Java RCE, known exploit patterns |
AWSManagedRulesSQLiRuleSet | 200 | SQL injection |
AWSManagedRulesPHPRuleSet | 100 | PHP-specific injections |
AWSManagedRulesWordPressRuleSet | 100 | WordPress-specific exploits |
AWSManagedRulesLinuxRuleSet | 200 | Linux LFI attacks |
| Total | ~1,675 | of 5,000 WCU limit |
3.2 WordPress-Specific Rule Group
AWSManagedRulesWordPressRuleSet (WCU: 100) protects against:| Rule | Protection |
|---|---|
WordPressExploitablePaths_URIPATH | Blocks xmlrpc.php and other exploitable files |
WordPressExploitableCommands_QUERYSTRING | Blocks 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:
- Override to Count + Label + Custom Rule (recommended, consistent with Section 3.4 pattern):
- Set
AdminProtection_URIPATHto 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
- Set
- 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. - 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:
- Override problematic rules to Count (they still inspect and apply labels, but don't block)
- 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
| Priority | Rule | WCU |
|---|---|---|
| 1 | Block wp-config.php (custom) | 10 |
| 2 | Anti-DDoS (AMR) — needs to see all traffic early to build complete baseline | 50 |
| 3 | Global rate limit 2000 req/5min per IP (custom) | 2 |
| 5 | Rate limit wp-login.php 100 req/5min per IP (custom) | 12 |
| 6 | Geo-block wp-admin (custom) | ~12 |
| 10 | IP Reputation List (AMR) | 25 |
| 30 | Common Rule Set — CRS (AMR, with Count overrides) | 700 |
| 35 | Block XSS Body except admin (custom, matches CRS label) | ~12 |
| 40 | Admin Protection (AMR) | 100 |
| 50 | Known Bad Inputs (AMR) | 200 |
| 60 | SQLi Rule Set (AMR) | 200 |
| 70 | PHP Rule Set (AMR) | 100 |
| 80 | WordPress Rule Set (AMR) | 100 |
| 90 | Linux 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
IPaggregate — CloudFront forwards true client IP
4. Summary
| Challenge | Solution |
|---|---|
| WordPress cookies fragment cache | CloudFront Function strips cookies for anonymous visitors; logged-in users bypass via origin Cache-Control |
| Cache invalidation on publish | Tag-based invalidation (semantic, precise) or short TTL (simple) |
| xmlrpc.php / wp-login.php attacks | AWSManagedRulesWordPressRuleSet + rate limiting custom rule |
| Admin editing triggers XSS false positives | Count + Label + Custom Rule: block everywhere except /wp-admin |
| WooCommerce session pages | Separate bypass cache behaviors + CFF query string detection |
| wp-cron breaks under CDN | Disable 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.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article