Zum Inhalt springen
EdgeServers
Blog

apache

ModSecurity and the OWASP CRS — the WAF rules we actually ship on Apache

Most ModSecurity installs are either off by default or so noisy nobody reads the logs. Here's how we tune it to be useful without drowning in false positives.

14. Mai 2026 · 10 min · von Sudhanshu K.

ModSecurity and the OWASP CRS — the WAF rules we actually ship on Apache

ModSecurity is one of those tools that almost everyone has installed and almost nobody has tuned. The pattern in audits is consistent: the module is loaded, the OWASP Core Rule Set is dropped in at default settings, the logs fill up with thousands of alerts that nobody reads, and either (a) it's been quietly running in DetectionOnly mode for years or (b) it's blocking legitimate traffic and the customer doesn't know.

Both failure modes mean the WAF is doing nothing useful. This post is about what tuning ModSecurity to be useful actually looks like, and what we ship on every managed Apache install by default.

Why ModSecurity is still worth running in 2026

Cloud WAFs (AWS WAF, Cloudflare, Azure Front Door) are good and getting better. They handle most of the volume and most of the obvious attacks before traffic ever reaches the origin. So why bother with origin-level ModSecurity in 2026?

Three reasons:

  1. Defence in depth. The cloud WAF is a single layer. When it misclassifies, or when the customer hasn't enabled a particular rule pack, or when an attacker finds a way around (origin IP discovery is still surprisingly common), origin-level WAF catches what edge missed.
  2. Application-specific rules. Cloudflare's WAF doesn't know that your /admin/export endpoint should never see a SELECT keyword in any query string. ModSecurity at the origin can.
  3. Forensics. When something does land, ModSecurity's audit log gives you the full request that triggered the rule. Cloud WAFs typically give you sampled, lossy data. The audit log is where post-incident analysis actually happens.

We don't deploy ModSecurity instead of a cloud WAF. We deploy it as the inner layer behind one. They catch different things.

The base configuration we deploy

The bones of a working ModSecurity install. Note we're talking about ModSecurity 3 (libmodsecurity) with the Apache connector, not the legacy ModSecurity 2 — version 3 is materially faster and has cleaner internals:

# /etc/apache2/mods-available/security2.conf
<IfModule security2_module>
    SecRuleEngine On
    SecRequestBodyAccess On
    SecResponseBodyAccess Off
    SecRequestBodyLimit 13107200
    SecRequestBodyNoFilesLimit 131072
    SecPcreMatchLimit 500000
    SecPcreMatchLimitRecursion 500000
 
    SecAuditEngine RelevantOnly
    SecAuditLogRelevantStatus "^(?:5|4(?!04))"
    SecAuditLogParts ABIJDEFHZ
    SecAuditLogType Serial
    SecAuditLog /var/log/apache2/modsec_audit.log
 
    SecDataDir /var/cache/modsecurity
    SecTmpDir /tmp
    SecDefaultAction "phase:1,log,auditlog,pass"
    SecDefaultAction "phase:2,log,auditlog,pass"
 
    Include /etc/modsecurity/crs/crs-setup.conf
    Include /etc/modsecurity/crs/rules/*.conf
</IfModule>

A few notes on these defaults:

  • SecResponseBodyAccess Off — we deliberately don't inspect response bodies. The performance cost is real, the catch rate on response inspection is low for most workloads, and turning it on is the single biggest source of memory pressure we see in ModSecurity deployments. If you have a specific use case (DLP, response-side data leak detection), turn it on selectively for those endpoints. Otherwise leave it off.
  • SecAuditLogRelevantStatus "^(?:5|4(?!04))" — log audits for 5xx and 4xx-not-404. Logging every 404 fills disks fast on sites with active scanner traffic.
  • SecAuditLogParts ABIJDEFHZ — these are the parts of each request/response we want in the audit log. A (header), B (request body), I (req body without files), J (file uploads), D (intermediary response), E (response body if enabled), F (response headers), H (action), Z (terminator). Skip C (raw request body — duplicates B) and K (matched rules — useful but verbose).

The OWASP Core Rule Set, paranoia level by paranoia level

The OWASP Core Rule Set (CRS) is the de facto standard rule set for ModSecurity. It's structured around the concept of paranoia levels:

  • PL1 — high-signal rules. False positive rate roughly 1-5% on most apps. The default.
  • PL2 — adds rules that catch more sophisticated attacks. FP rate around 5-15%.
  • PL3 — adds rules that flag suspicious-looking patterns. FP rate 15-30%.
  • PL4 — extremely strict. Will block routinely legitimate requests on most apps. FP rate 30%+.

The fantasy is that you start at PL1, tune the false positives, move to PL2, tune more false positives, and so on. The reality is that PL3 and PL4 are unmaintainable on any real application. We deploy at PL1 with selected PL2 rules turned on per-application, and that's what we recommend for almost every customer.

Setting the paranoia level in crs-setup.conf:

SecAction \
 "id:900000,\
  phase:1,\
  nolog,\
  pass,\
  t:none,\
  setvar:tx.blocking_paranoia_level=1,\
  setvar:tx.detection_paranoia_level=2"

The trick here is the difference between blocking_paranoia_level and detection_paranoia_level. Setting blocking at 1 and detection at 2 means we log the PL2 rules without acting on them. That gives us a feedback signal — if a PL2 rule fires repeatedly on legitimate traffic, we don't promote it. If a PL2 rule catches genuine attack patterns that PL1 missed, we promote it selectively.

Tuning out false positives, the right way

The wrong way to deal with a false positive: write a rule that excludes the entire endpoint. The right way: exclude the specific argument or specific rule.

The CRS provides exclusion mechanisms designed for this. Suppose 949110 - Inbound Anomaly Score Exceeded is firing on POST /api/orders because the request body contains a long JSON payload that looks SQL-ish (it has the word SELECT in a comment field). The correct fix is in REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf:

# Exclude rule 942100 (SQLi detection) from POST /api/orders requests
SecRule REQUEST_URI "@beginsWith /api/orders" \
    "id:1000,\
     phase:1,\
     pass,\
     nolog,\
     ctl:ruleRemoveTargetById=942100;ARGS:notes"

This removes rule 942100 from inspecting the notes argument when the URI is /api/orders. It still inspects every other argument; it still inspects notes on every other endpoint. Targeted exclusions are the difference between a WAF that works and a WAF that has been silently disabled by 200 lines of broad exclusions.

We maintain per-customer exclusion files and review them quarterly. Exclusions that haven't been touched in a year usually mean the original problem is gone and the exclusion can be removed; we test by removing it and watching the audit log.

The rules we always enable beyond PL1

A handful of CRS rules that aren't in PL1 by default but that we promote on essentially every customer install:

  • 920420 — Restricted Content Type — blocks weird Content-Type headers like text/xml on endpoints that expect application/json. Catches a lot of legacy XML-injection probes.
  • 920430 — Restricted HTTP Version — blocks HTTP/0.9 and other oddities. Anything that doesn't speak HTTP/1.1 or HTTP/2 properly is a scanner.
  • 913100-913120 — Bot detection by UA string — blocks nikto, sqlmap, nessus, acunetix, etc. by name. Won't stop a determined attacker (they'll change the UA), but stops a lot of routine probing in a single request.
  • 930120 — OS file access attempt — catches /etc/passwd, /proc/self/environ, and similar in URLs. Trivial to bypass with encoding, but the unencoded version is so common that this rule pays for itself.

We also write a small number of custom rules per customer for application-specific patterns. The general structure of a useful custom rule:

# Block any request to /admin/export containing SQL keywords
SecRule REQUEST_URI "@beginsWith /admin/export" \
    "id:1100,\
     phase:2,\
     deny,\
     status:403,\
     log,\
     msg:'SQL keyword in admin export endpoint',\
     chain"
    SecRule ARGS|REQUEST_BODY "@rx (?i)(union|select|insert|update|delete|drop)\s" \
        "t:none,t:urlDecodeUni,t:lowercase"

This is the kind of rule that catches the attack a generic SQLi detector misses because the payload is encoded in a way the detector wasn't taught. It's narrow, it's loud, and it almost never fires on legitimate traffic.

The false-positive feedback loop

The hardest part of running ModSecurity isn't the initial config; it's the ongoing tuning. We treat the audit log as a stream that needs reviewing, not an archive.

Our cadence:

  • Daily — automated aggregation of triggered rule IDs, grouped by URI and source IP. Anything that fires more than 50 times per day on a single endpoint goes to a triage queue.
  • Weekly — human review of the triage queue. Decisions: tune out the false positive, promote the rule (if it's catching real attacks), or escalate to an engagement for a deeper look.
  • Monthly — review of the exclusion file. Old exclusions get tested for removal. New patterns (per-app) get added.
  • Quarterly — full review against the latest CRS release. Rule updates are real; the CRS gets meaningfully better every release.

This sounds expensive. It isn't. Across 50 customer sites, the actual time investment is maybe 4 hours per week of analyst time. The alternative — running ModSecurity in DetectionOnly forever because tuning it feels hard — is what we're trying to avoid.

For sites that are under active targeting, we tighten the feedback loop and pair it with a pen-testing engagement — the test surfaces gaps in the rule set, the rule set tightens, the next test surfaces the next layer.

What we ship by default

For every customer running Apache under our cybersecurity service:

  • ModSecurity 3 with Apache connector, latest stable
  • OWASP CRS 4.x at PL1 blocking / PL2 detection
  • Customer-specific exclusion file in REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf
  • Promoted ruleset for the high-signal PL2+ rules listed above
  • Audit logging to a central log pipeline, retained 90 days
  • Daily anomaly digest to the customer's security contact
  • Quarterly tuning review with a written report
  • Optional: a small set of application-specific custom rules

ModSecurity at this configuration adds roughly 5-10% CPU overhead under normal load and catches attacks that the cloud WAF in front of it routinely lets through. The numbers vary — we've had customers where origin ModSecurity caught more in a month than the cloud WAF caught in a quarter, and others where the cloud WAF caught everything and origin was belt-and-braces. Either way, the cost is low and the worst case is "you were already safe."

If you've got Apache running in production and ModSecurity has been on DetectionOnly since 2022, you've got a free win waiting. The audit log probably has six months of evidence about what's been knocking on your door. The hard part isn't deciding to turn it on; it's tuning out the noise so the alerts that fire actually mean something. That's the part our managed service handles week to week.

Sudhanshu K. leads cybersecurity engagements at EdgeServers, a unit of RemotIQ Pty Ltd (ABN 91 682 628 128). She has read more ModSecurity audit logs than is healthy and maintains strong opinions about paranoia level 4.