EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Security / libmodsecurity3 + nginx — only ctl:ruleEngine exclusions work
Security

libmodsecurity3 + nginx — only ctl:ruleEngine exclusions work

by Ton-Or ·10 Jul 2026·9 min read
libmodsecurity3 + nginx — only ctl:ruleEngine exclusions work

Most ModSecurity information on the internet comes from the v2 era on Apache — apply it to libmodsecurity3 on nginx and many things do not work. Worse, they fail silently, with no error to tell you. This article is the result of trying every approach until I found one I could actually rely on.

If you use ModSecurity v3 (libmodsecurity3) with nginx + OWASP CRS and have tried to add a false positive exclusion for a website, this article might save you several hours. Most information on the internet was written in the ModSecurity v2 (Apache) era, and much of it does not work with v3 — silently, with no error to tell you.

The domain names in the examples are fictional.

The initial problem: WooCommerce blocked by the WAF

A client's WooCommerce site was getting blocked when customers placed orders. The browser showed HTTP 406 (I configured the WAF to return 406 when blocking so I could immediately distinguish it from the app's 403 and tell which one had blocked the request). I checked the audit log:

tail -1 /var/log/nginx/modsec_audit.log | python3 -m json.tool | grep -E 'ruleId|match|uri'
# ruleId 942430 — Restricted SQL Character Anomaly Detection
# uri: /?wc-ajax=checkout

What actually happened was that the customer entered an address containing special characters, combined with a long serialized wc-ajax payload — CRS counted the suspicious characters and exceeded the threshold, a classic WooCommerce false positive.

What most documentation recommends (and does not work)

The standard CRS guides say to add a runtime exclusion like this:

SecRule REQUEST_URI "@contains wc-ajax" \
    "id:99001,phase:1,pass,nolog,ctl:ruleRemoveById=942430"

I added it and reloaded nginx — still exactly the same 406. No error, no warning in any log, as though my rule did not exist. So I worked through every approach in the guides:

  • ctl:ruleRemoveById=942430 — silent, no effect
  • ctl:ruleRemoveTargetById=942430;ARGS:billing_address_1 — silent, no effect
  • SecRuleUpdateTargetById (at load time) — no effect either
  • ctl:ruleEngine=Off — works
  • ctl:ruleEngine=DetectionOnly — works (logs but does not block)
Not a bug specific to my machine

This is a long-known limitation in the libmodsecurity3 issue tracker — runtime rule removal in the ruleRemove family is incomplete or does not work in v3, especially when the target rule is chained or in a different phase. The only family I can still reliably use is ctl:ruleEngine.

The approach I use: disable the engine within the narrowest possible scope

Since disabling the whole engine is the only option that works, my job is to make the scope narrow enough to be acceptable — match both Host and URI with a chained rule:

# ปิด WAF เฉพาะ wc-ajax ของโดเมนเดียว เท่านั้น
SecRule REQUEST_HEADERS:Host "@streq shop-example.com" \
    "id:99009,phase:1,pass,nolog,chain"
    SecRule REQUEST_URI "@rx (?i)[?&]wc-ajax=" "ctl:ruleEngine=Off"

# หน้าแอดมินระบบภายใน: ไม่ปิดสนิท แต่เปลี่ยนเป็น log-only (ยังเห็นใน audit log)
SecRule REQUEST_HEADERS:Host "@streq panel-example.com" \
    "id:99011,phase:1,pass,nolog,chain"
    SecRule REQUEST_URI "@rx ^/admin/" "ctl:ruleEngine=DetectionOnly"

I use two levels: for paths whose payloads are "inherently dirty" (wc-ajax, CMS editors), I set it to Off. For paths with only occasional false positives, I use DetectionOnly so I can go back through the logs later.

File placement matters: it must load "before" CRS

I once put the exclusions in a file named zzz-exclusions.conf (intending it to load last, as I usually do with config files) — CRS phase:1 rules (such as 920440) still blocked requests because they had already finished processing before my exclusions were reached.

Runtime ctl exclusions must go in the file CRS provides specifically for them, which loads before all the rules:

/etc/modsecurity.d/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf

(Its counterpart is RESPONSE-999-...-AFTER-CRS.conf for SecRuleUpdate* directives — which, as I mentioned, you should not count on in v3 yet.)

Correct placement: before CRSREQUEST-900-EXCLUSION-RULES-BEFORE-CRS.confLoads before all rulesCRS rulese.g. 920440 phase 1PassAllowed by WAFWrong placement: zzz-exclusions.conf loads lastCRS rulese.g. 920440 phase 1zzz-exclusions.confRead later, too late406Still blockedCRS finishes processing before our file is reached
The exclusion file in REQUEST-900 BEFORE-CRS is read before the CRS rules, while zzz-exclusions.conf is read afterward. CRS phase 1 rules have already finished processing and still block the request.

How to test without fooling yourself

Two traps I ran into along the way:

1. Identify what is blocking the request — 406 = ModSecurity (in my config), 403 = the app/WordPress nonce itself. I once spent half an hour fixing the WAF when the actual cause was an expired WooCommerce nonce.

2. Always test through the public IP — running curl on the server against 127.0.0.1 hits the default vhost, not the domain's vhost with that WAF config:

curl -sk --resolve shop-example.com:443:203.0.113.10 \
  'https://shop-example.com/?wc-ajax=checkout' \
  -X POST -d 'billing_address_1=1/2--3' \
  -o /dev/null -w '%{http_code}\n'
# ก่อนแก้: 406 / หลังแก้: 403 (= WAF ปล่อยผ่านแล้ว ที่เหลือ nonce ของ Woo ตามปกติ)

Summary

  1. libmodsecurity3 (v3) + nginx: ctl:ruleRemoveById and its relatives fail silently — do not waste your time
  2. What actually works: ctl:ruleEngine=Off|DetectionOnly, scoped with a Host+URI chained rule
  3. The exclusion file must load before CRS (REQUEST-900-...-BEFORE-CRS.conf)
  4. Set the block response code to differ from the app's (406 vs 403); it makes life much easier
  5. Test through the public IP, not 127.0.0.1

If you are still using ModSecurity v2 on Apache, the old guides still apply. But if you move to v3 on nginx, remember just one thing: trust only ctl:ruleEngine, and always test everything else before trusting it. If I have left anything out or made a typo, I will come back and fix it later.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first