ModSecurity Was Silently Rejecting WooCommerce Checkout Requests — How I Traced the 403s to Firewall Rules
· 9 min read
Orders Were Failing and Nobody Knew Why
A client's WooCommerce store had been losing orders for two days before anyone noticed. Customers were filling in checkout forms, clicking "Place Order," and getting a blank white page. No error message, no confirmation, no order in the WooCommerce dashboard.
The store was on a VPS managed through CloudPanel, running nginx with PHP-FPM 8.2. I'd set up the server myself six months earlier. It had been running without issues — until the hosting provider pushed a ModSecurity rule update overnight.
The First Clue: 403 in the Browser Console
The client had forwarded a screenshot from a frustrated customer showing a blank page. I opened the checkout in Chrome DevTools and watched the network tab while submitting a test order. The POST request to /?wc-ajax=checkout came back with a 403 Forbidden status.
A 403 is never WordPress. WordPress returns 200, 302, 404, 500 — but not 403 on its own checkout endpoint. A 403 on a POST request points to something intercepting the request before PHP ever sees it: a web server rule, a security plugin with firewall capabilities, or a server-level WAF.
I checked the nginx error log first:
tail -100 /var/log/nginx/error.log | grep -i "403\|denied\|forbidden"
Nothing relevant. Nginx wasn't the one rejecting it.
Finding the ModSecurity Audit Log
On this server, ModSecurity was running as an nginx module with the OWASP Core Rule Set (CRS). The audit log location depends on your setup — common paths include:
/var/log/modsec_audit.log
/var/log/apache2/modsec_audit.log
/var/log/nginx/modsec_audit.log
/var/log/modsecurity/audit.log
On CloudPanel, it was at /var/log/nginx/modsec_audit.log. I searched for recent 403 actions:
grep -A 10 "id \"942" /var/log/nginx/modsec_audit.log | tail -40
The log was full of entries like this:
ModSecurity: Access denied with code 403 (phase 2).
Matched "Operator `Rx' with parameter [...]
against variable `ARGS:billing_address_1'
(Value: `14 St James's Street')
[id "942100"] [msg "SQL Injection Attack Detected via libinjection"]
[severity "CRITICAL"]
Rule 942100 — the libinjection SQL injection heuristic — was triggering on the customer's billing address. The apostrophe in "St James's Street" was enough to flag it as a potential SQL injection.
I searched for more hits across the log:
grep "942100\|941100\|932130\|920230" /var/log/nginx/modsec_audit.log | wc -l
47 blocked requests in the past 48 hours. Every single WooCommerce checkout submission with an apostrophe, angle bracket, or certain special characters in any form field was being silently rejected.
It Wasn't Just Checkout
Once I started looking, the problem was wider than checkout. I found three other false positive patterns in the same log:
Saving blog posts in the WordPress editor — rule 941100 (XSS detection) was triggering when posts contained HTML code snippets or embedded shortcodes. The Gutenberg block editor sends the full post content as a POST parameter, and any legitimate HTML triggered the cross-site scripting heuristic.
WooCommerce product descriptions — rule 932130 (Remote Command Execution) was flagging product descriptions that contained backtick characters or pipe symbols, common in technical product specs.
Plugin settings pages — several plugin settings that accepted CSS or JavaScript configuration (Google Tag Manager container IDs, custom CSS fields) were being blocked by rule 941100.
The client had been unable to update product descriptions for two days and had assumed it was "WordPress being slow." They'd been refreshing the page and trying again, not realising the save was being actively blocked.
Why This Happens: CRS Anomaly Scoring vs. WordPress Reality
The OWASP Core Rule Set is designed for generic web applications. It inspects POST body parameters for patterns that look like SQL injection, XSS, remote code execution, and other attacks. The detection is pattern-based — it looks for strings that could be malicious without understanding the application context.
WordPress and WooCommerce are essentially a CMS that lets users submit rich text, HTML, and arbitrary form data through POST requests. That's exactly the pattern that CRS is designed to flag.
The problem gets worse at higher CRS paranoia levels. Most hosting providers run at paranoia level 1 (the default), which catches the obvious attacks but lets most legitimate traffic through. Some providers — or some security-conscious administrators — run at level 2 or 3, which dramatically increases the false positive rate on WordPress sites.
I checked the paranoia level on this server:
grep "tx.paranoia_level" /etc/nginx/modsec/crs-setup.conf
SecAction "id:900000,phase:1,pass,t:none,nolog,setvar:tx.paranoia_level=2"
Paranoia level 2. The hosting provider had bumped it from 1 to 2 in their rule update — that's what caused the sudden wave of false positives. At level 1, rule 942100 uses stricter matching that avoids most apostrophe-in-address scenarios. At level 2, the libinjection heuristic casts a wider net.
The Fix: Surgical Rule Exclusions
The wrong approach is to disable ModSecurity entirely. I've seen this done — a SecRuleEngine Off in the site config that stays forever. You lose all WAF protection to fix a checkout problem.
The correct approach is to exclude specific rules from specific parameters on specific paths. ModSecurity's SecRuleUpdateTargetById directive does exactly this.
I created an exclusion file for the WordPress and WooCommerce paths that were being hit:
# WooCommerce checkout — exclude billing/shipping fields from SQLi and XSS checks
SecRule REQUEST_URI "^/(\?wc-ajax=|wc-api/)" \
"id:10001,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=942100;ARGS:billing_address_1,\
ctl:ruleRemoveTargetById=942100;ARGS:billing_address_2,\
ctl:ruleRemoveTargetById=942100;ARGS:billing_city,\
ctl:ruleRemoveTargetById=942100;ARGS:billing_company,\
ctl:ruleRemoveTargetById=942100;ARGS:shipping_address_1,\
ctl:ruleRemoveTargetById=942100;ARGS:shipping_address_2,\
ctl:ruleRemoveTargetById=942100;ARGS:shipping_city,\
ctl:ruleRemoveTargetById=942100;ARGS:shipping_company,\
ctl:ruleRemoveTargetById=942100;ARGS:order_comments"
# WordPress post editor — exclude content field from XSS and RCE checks
SecRule REQUEST_URI "^/wp-admin/post\.php" \
"id:10002,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=941100;ARGS:content,\
ctl:ruleRemoveTargetById=941100;ARGS:excerpt,\
ctl:ruleRemoveTargetById=932130;ARGS:content"
# WordPress plugin/theme settings — exclude common settings parameters
SecRule REQUEST_URI "^/wp-admin/(options|admin-ajax|admin-post)\.php" \
"id:10003,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=941100;ARGS,\
ctl:ruleRemoveTargetById=942100;ARGS"
On this CloudPanel server, the exclusion file went into /etc/nginx/modsec/ and was included from the main ModSecurity configuration:
echo "Include /etc/nginx/modsec/wordpress-exclusions.conf" >> /etc/nginx/modsec/modsecurity.conf
nginx -t && systemctl reload nginx
For cPanel/WHM servers, the same exclusions go into the ModSecurity pre-rules configuration under WHM > Security Center > ModSecurity Vendors > Edit Rules. For Plesk, it's under Tools & Settings > Web Application Firewall > ModSecurity Directives.
After reloading nginx, I tested checkout again. The order went through cleanly. I tested with addresses containing apostrophes, ampersands, and hash symbols — all fine.
The Better Fix for CRS 4: The WordPress Exclusions Plugin
If your server runs OWASP CRS version 4 (released in 2022, now widely adopted), there's a proper solution: the WordPress rule exclusions plugin maintained by the CRS project itself.
In CRS 3, you could set tx.crs_exclusions_wordpress=1 in crs-setup.conf to enable built-in WordPress exclusions. CRS 4 replaced this with a plugin system. The WordPress plugin contains carefully maintained exclusions for all the common WordPress and WooCommerce false positives — including the exact rules I was hitting.
To install it:
cd /etc/nginx/modsec/coreruleset/plugins/
git clone https://github.com/coreruleset/wordpress-rule-exclusions-plugin.git
Then include the plugin's rules in your CRS configuration, before and after the main CRS rules:
Include /etc/nginx/modsec/coreruleset/plugins/wordpress-rule-exclusions-plugin/plugins/*-before.conf
Include /etc/nginx/modsec/coreruleset/rules/*.conf
Include /etc/nginx/modsec/coreruleset/plugins/wordpress-rule-exclusions-plugin/plugins/*-after.conf
This is more maintainable than hand-crafted exclusions. The plugin is updated alongside CRS releases, so when new rules are added that might cause WordPress false positives, the exclusions get updated too.
The WooCommerce Order Attribution Cookie Problem
While investigating, I also found a separate 403 pattern that deserves its own mention. WooCommerce 8.5 introduced Order Attribution tracking, which sets cookies containing referrer and session data. The cookie values contain strings like https:// and URL-encoded characters that some WAF rulesets — particularly the Comodo WAF ruleset (rule 218500) — flag as malicious.
The symptom is identical: 403 on checkout POST requests. But the trigger is the cookie header, not the form fields. The WooCommerce audit log entry will reference the Cookie request header rather than ARGS.
If you're seeing this pattern specifically, WooCommerce 9.0+ includes a filter to Base64-encode the cookie values, which avoids the WAF trigger:
add_filter( 'wc_order_attribution_use_base64_cookies', '__return_true' );
Add that to your theme's functions.php or a site-specific plugin. Alternatively, disable Order Attribution entirely under WooCommerce > Settings > Advanced > Features if you don't use it.
How I Catch This Before Customers Do
ModSecurity false positives are insidious because they produce no PHP error, no WordPress error log entry, and no WooCommerce order note. Unlike a 502 from nginx buffer issues or a PHP-FPM worker exhaustion event, there's nothing in the application layer to alert on. From the application's perspective, the request never arrived. The only evidence is in the ModSecurity audit log, which most site owners never check.
I now monitor the ModSecurity audit log on every server I manage. A simple cron job counts 403 actions per hour and alerts if the count exceeds a threshold:
#!/bin/bash
COUNT=$(grep -c "Access denied with code 403" /var/log/nginx/modsec_audit.log)
if [ "$COUNT" -gt 10 ]; then
echo "ModSecurity blocked $COUNT requests in the last hour" | \
mail -s "WAF Alert: $(hostname)" [email protected]
fi
I also run a synthetic checkout test in Uptime Kuma that submits a POST to the WooCommerce AJAX checkout endpoint with a test payload containing an apostrophe. If it returns anything other than the expected WooCommerce JSON response, I know the WAF is interfering.
After this incident, the client's checkout conversion rate went back to normal within a day. Forty-seven lost orders in 48 hours — each one a customer who tried to buy, got a blank page, and left. The server firewall was doing its job protecting against attacks. It just couldn't tell the difference between "O'Brien" and ' OR 1=1--.
ModSecurity tuning is one of those server-level tasks that falls outside most WordPress support agreements but directly affects revenue. I configure and monitor WAF rules across the 70+ sites I maintain. If your WooCommerce store is throwing unexplained 403 errors, take a look at my maintenance plans — or read more about WooCommerce maintenance and server management.
