nginx: ModSecurity v3 WAF with OWASP CRS 4 on Ubuntu
A Web Application Firewall (WAF) inspects HTTP requests before they reach your application and blocks known attack patterns — SQL injection, cross-site scripting, path traversal, and similar. This guide sets up ModSecurity v3 with the OWASP Core Rule Set (CRS) 4 on nginx mainline, from scratch.
This article is part of a three-layer hardening series for nginx:
- Block direct IP access — who is asking at all?
- Rate limiting — how often are they asking?
- ModSecurity + CRS (this article) — what are they sending?
Isn't ModSecurity Dead?
You may have read that ModSecurity is end-of-life. That is a half-truth worth untangling:
- ModSecurity v2 (the Apache-era module) is legacy. This guide uses v3 (libmodsecurity), a rewrite as a standalone library.
- Trustwave ended its commercial support on 2024-07-01 — but the project was handed over to the OWASP Foundation in early 2024 and is community-maintained there (owasp-modsecurity/ModSecurity, owasp-modsecurity/ModSecurity-nginx).
- F5 declared its commercial product "NGINX ModSecurity WAF" end-of-life. That affects F5 customers only, not this open-source setup.
- OWASP Coraza (written in Go, SecLang-compatible, CRS-4-native) is the designated successor — but as of June 2026 it has no production-ready native nginx module; nginx support is experimental (proxy-wasm). For a natively installed nginx, libmodsecurity v3 plus the connector module remains the standard path.
Conclusion: use ModSecurity v3, keep the CRS current, and keep an eye on Coraza for the future.
Architecture — Three Separate Components
The setup consists of three parts with three independent update cycles. Understanding this separation explains every maintenance task later on:
| Component | What it is | Updated via |
|---|---|---|
| libmodsecurity3 | The WAF engine, a shared library | apt — ABI-stable, uncritical |
| ModSecurity-nginx connector | A thin dynamic nginx module that feeds requests into the engine | Manual rebuild — tied to the exact nginx version |
| OWASP CRS 4 | The rule set — plain text files, independent of the module | Release tarball |
The connector is the operational pain point: dynamic nginx modules are binary-compatible only with the exact nginx version they were built against. After every nginx update, the module must be rebuilt. Everything else is routine.
Prerequisites
- Ubuntu 22.04 or 24.04
- nginx mainline installed natively from the official nginx.org repository — not the Ubuntu distro package, which uses different paths and versions
- Root or sudo access
- A working site in nginx to protect
Install the build dependencies and the engine:
sudo apt-get update
sudo apt-get install -y libmodsecurity3 libmodsecurity-dev \
gcc make libpcre2-dev zlib1g-dev git wget
libmodsecurity3 is the WAF engine itself. libmodsecurity-dev provides the headers needed to compile the connector. The remaining packages are the standard toolchain for building an nginx module.
Step 1 — Build the Connector Module
The connector must be configured against the source code of the exact nginx version you are running. Even a patch-level difference causes nginx to refuse the module at startup with module is not binary compatible.
First, detect the installed version and download the matching source:
NGINX_VERSION="$(nginx -v 2>&1 | sed -E 's:.*/([0-9.]+).*:\1:')"
wget "https://nginx.org/download/nginx-$NGINX_VERSION.tar.gz"
tar xzf "nginx-$NGINX_VERSION.tar.gz"
Clone the connector and build the module. The project lives under the OWASP organization since 2024 — do not use older SpiderLabs URLs:
git clone --depth=1 https://github.com/owasp-modsecurity/ModSecurity-nginx.git
cd "nginx-$NGINX_VERSION"
./configure --with-compat --add-dynamic-module=../ModSecurity-nginx
make modules
sudo cp objs/ngx_http_modsecurity_module.so /usr/lib/nginx/modules/
--with-compat makes the module binary-compatible with the official nginx packages. make modules builds only the module, not all of nginx.
Load the module in /etc/nginx/nginx.conf — the load_module line must come at the very top, before the events and http blocks:
Restart, don't reload, after swapping the module
After replacing the .so file, run systemctl restart nginx — not reload. A graceful reload keeps old workers running against a module file that changed underneath them, which risks crashes. And remember: repeat this build after every nginx update. Automating it with a script or apt hook is strongly recommended — see nginx-helper-scripts for a ready-made rebuild script.
Step 2 — Configure the Engine
ModSecurity ships a recommended baseline configuration. Copy it together with the unicode mapping file into a dedicated directory:
sudo mkdir -p /etc/nginx/modsec
sudo wget -O /etc/nginx/modsec/modsecurity.conf \
https://raw.githubusercontent.com/owasp-modsecurity/ModSecurity/v3/master/modsecurity.conf-recommended
sudo wget -O /etc/nginx/modsec/unicode.mapping \
https://raw.githubusercontent.com/owasp-modsecurity/ModSecurity/v3/master/unicode.mapping
The defaults in modsecurity.conf are sensible. Three directives deserve a closer look:
SecRuleEngine DetectionOnly — the default, and the mode you must start in. The engine evaluates all rules and logs what would be blocked, but blocks nothing. Switching straight to blocking on day one is how legitimate users get locked out by false positives. The operating workflow below covers the transition.
SecRequestBodyLimit / SecRequestBodyNoFilesLimit — maximum request body sizes the engine accepts. Align these with client_max_body_size in your nginx config; if ModSecurity's limit is lower, uploads fail with misleading 413 errors even though nginx would allow them.
Audit logging — confirm these lines are set; the audit log is your primary tool during the tuning phase:
Enable the engine globally in the http {} block of your nginx configuration:
Step 3 — Install OWASP CRS 4
The engine alone does nothing — it needs rules. The OWASP Core Rule Set is the de-facto standard generic rule set.
Do not install the CRS via apt
apt install modsecurity-crs ships CRS 3.3.x, whose support ends Q3 2026. The current line is CRS 4 (4.27.0 as of June 2026). For production servers, use the 4.25.x LTS line — 4.25.0 is the first long-term-support release of the CRS 4 series.
Download the release tarball. Check the releases page for the current version first — verify the published checksums and signatures while you are there:
CRS_VERSION="4.25.0" # check https://github.com/coreruleset/coreruleset/releases
wget "https://github.com/coreruleset/coreruleset/archive/refs/tags/v$CRS_VERSION.tar.gz"
sudo mkdir -p /etc/nginx/crs
sudo tar xzf "v$CRS_VERSION.tar.gz" --strip-components=1 -C /etc/nginx/crs
sudo cp /etc/nginx/crs/crs-setup.conf.example /etc/nginx/crs/crs-setup.conf
Include the rules at the end of /etc/nginx/modsec/modsecurity.conf. The order is semantically relevant: crs-setup.conf defines variables the rules depend on, exclusions placed before the rules can disable them at runtime, and score adjustments must come after:
Include /etc/nginx/crs/crs-setup.conf
# Custom exclusions BEFORE the CRS rules (runtime rule exclusions):
# Include /etc/nginx/modsec/exclusions-before-crs.conf
Include /etc/nginx/crs/rules/*.conf
# Custom exclusions AFTER the CRS rules (score adjustments etc.):
# Include /etc/nginx/modsec/exclusions-after-crs.conf
Validate and restart:
Then send an obvious attack pattern and check the reaction:
In DetectionOnly mode this returns 200 but writes an entry to /var/log/nginx/modsec_audit.log. Once the engine is set to On, the same request returns 403.
Understanding the CRS: Anomaly Scoring and Paranoia Levels
The CRS does not block on individual rule hits. Instead, every matching rule adds points to an anomaly score, and the request is blocked only when the score crosses a threshold (inbound_anomaly_score_threshold, default 5 — a single critical-severity rule is enough). This is why tuning works the way it does: a borderline request might trip two low-severity rules and still pass.
Paranoia Levels (PL) 1–4 control how aggressive the rule set is:
| Level | Character | Use for |
|---|---|---|
| PL1 | Default; very few false positives | Start here — every setup |
| PL2 | More rules, some false positives | Security-sensitive services, after tuning PL1 |
| PL3–4 | Strict; substantial false positives | Only with intensive, ongoing tuning |
For a self-hosted environment: start at PL1, consider PL2 later. The level is set in /etc/nginx/crs/crs-setup.conf via the tx.blocking_paranoia_level variable.
From DetectionOnly to Blocking
The transition is a workflow, not a switch:
1. Observe (week 1–2). Leave SecRuleEngine DetectionOnly active and use the protected services normally. Every rule that would have blocked something lands in the audit log.
2. Review the audit log. Group entries by rule ID to find the noisy ones:
Typical false-positive sources: WordPress editors, JSON APIs with unusual payloads, file uploads.
3. Write targeted exclusions instead of deleting rules globally. SecRuleRemoveById disables a rule everywhere and should be the last resort. Better: disable a rule for one specific argument on one specific route. Example — rule 942100 (SQL injection detection) falsely triggers on the body parameter of a CMS editor:
SecRule REQUEST_URI "@beginsWith /wp-admin/post.php" \
"id:1001,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942100;ARGS:body"
Place exclusions like this in the exclusions-before-crs.conf file from the include block above. Use IDs between 1 and 99,999 for your own rules — the CRS occupies 900,000 and up.
4. Switch to blocking. Set SecRuleEngine On in modsecurity.conf, restart nginx, and keep watching the audit log for stragglers.
5. Keep the rules current. Rule sets age fast: in January 2026 the CRS project disclosed a critical bypass in rule 922110 (CVE-2026-21876, CVSS 9.3, fixed in 4.22.0) that allowed charset-based attacks past the WAF entirely. Watch the GitHub releases or the CRS blog RSS feed; update monthly and apply security releases immediately.
Maintenance Plan
| Component | Update trigger | Action |
|---|---|---|
| nginx mainline | apt update | Rebuild connector module, systemctl restart nginx |
| libmodsecurity3 | apt update | Nothing — ABI-stable |
| CRS | Release / security advisory | Swap tarball, nginx -t, reload |
| Own exclusions | False-positive findings | Audit-log review, monthly |
A WAF is one layer, not a complete defense. Combine it with rate limiting and blocking direct IP access for defense in depth.
Common Issues
| Symptom | Cause | Fix |
|---|---|---|
module is not binary compatible on start |
Connector built against a different nginx version | Rebuild against the exact running version (nginx -v) |
| nginx crashes after module swap | reload instead of restart after replacing the .so |
Always systemctl restart nginx after a module rebuild |
Uploads fail with 413 despite client_max_body_size |
SecRequestBodyLimit lower than the nginx limit |
Align both limits |
| Legitimate requests blocked | False positive, often at PL2+ | Identify the rule ID in the audit log, write a targeted exclusion |
curl test returns 200 instead of 403 |
Engine still in DetectionOnly |
Expected during tuning; check the audit log instead |