Coraza WAF on NGINX: The Go-Powered ModSecurity Replacement

In July 2024, Trustwave handed ModSecurity to OWASP and called time on the engine that had guarded web apps since 2002. The reference WAF of the entire industry, the thing the OWASP Core Rule Set was written against, went into maintenance-only mode written in aging C++.

Coraza WAF is the answer to the obvious next question: what runs the CRS when ModSecurity finally stops? It’s a clean-room rewrite of the whole engine in Go, it reads your existing SecRule files without translation, and we package it for Debian and Ubuntu as a pair of .debs plus an NGINX dynamic module. This is the tour: what Coraza WAF is, why a Go firewall needs a C library bolted to its side, and when to pick it over the engine it replaces.

What Coraza WAF actually is

A web application firewall sits in front of your app and reads every request before your code does. SQL injection in a query string, a path-traversal ../../etc/passwd in a URL, a known exploit payload aimed at some WordPress plugin: the WAF matches it against a ruleset and blocks, logs, or scores it before your PHP ever wakes up. ModSecurity invented this pattern for the open web. The OWASP Core Rule Set (CRS) is the big free ruleset everyone uses on top of it.

Coraza is a from-scratch reimplementation of that engine in Go. Not a wrapper, not a port: a new codebase that happens to speak the same dialect. It implements the SecLang rule language, the same one you’ve been writing as SecRule REQUEST_URI "@rx evil" "id:1,phase:1,deny" for twenty years. It runs the OWASP CRS unmodified. If you already have a ModSecurity config, most of it drops straight in.

The reason it exists in Go and not C++ is the reason half of you already guessed. Memory safety. ModSecurity is a C++ codebase parsing hostile, attacker-controlled input on the hottest path in your stack, which is exactly the place you least want a buffer overflow.

A WAF with a memory-corruption bug is worse than no WAF: now the thing meant to stop the attacker is the attacker’s way in. Go won’t hand you a use-after-free in the request parser. That single property is most of the pitch.

Here’s the catch, and it’s the whole reason this article isn’t two sentences long. Go is a garbage-collected language with its own runtime and its own scheduler. NGINX is C. You cannot simply call Go from C the way you call a normal library. Bridging those two worlds is what the C library in the next section exists to do.

libcoraza: why a Go firewall needs a C library

NGINX speaks C. The Coraza engine is Go. Something has to translate, and that something is libcoraza: a thin C-bindings layer that compiles the Go engine into a C-callable shared object using cgo and Go’s c-shared build mode. The output is a real libcoraza.so with a header full of honest C functions: coraza_new_transaction, coraza_process_request_headers, coraza_process_response_body, and the rest. Your C code calls those. Inside, the Go runtime does the actual work.

We package libcoraza on its own, exactly like we package libmodsecurity3 for the old engine. Two binary packages come out of it:

  • libcoraza1: the runtime shared library, libcoraza.so.1, what your server actually loads.
  • libcoraza-dev: the header (coraza.h) and the unversioned libcoraza.so symlink, needed only at build time to compile something against it.

Building it is its own small adventure. The library needs the Go toolchain (currently Go 1.25), autotools, libtool, and a C compiler, because it’s a cgo build wrapped in an autoconf shell. That Go version requirement matters more than it looks, and I’ll come back to it when we talk about which distros get the module at all.

libcoraza also ships a SWIG interface file, which means the same C-callable engine can generate bindings for Python, Ruby, Java, PHP, and Perl. We don’t ship those. But it’s a nice reminder that this isn’t an NGINX-only project. libcoraza is the front door for embedding Coraza in any C-speaking application, and NGINX is just our first guest.

The nginx-coraza connector and its four directives

On top of libcoraza sits the actual NGINX module, ngx_http_coraza_module, built from the coraza-nginx connector. It’s a fork of the old ModSecurity-nginx connector, which is why the wiring feels familiar if you’ve ever run ModSec on NGINX. The module is the plumbing between NGINX’s request lifecycle and libcoraza’s transaction API: it hands each request’s headers, body, and response over to the engine at the right phase and acts on the verdict.

It adds four config directives, and that’s the whole surface area:

  • coraza on|off;: the master switch. Works in http, server, and location blocks, defaults to off. Turn it on where you want inspection, leave it off for your static asset locations so you’re not running the CRS against every favicon request.
  • coraza_rules_file /path/to/rules.conf;: point it at a rules file. This is where you load the CRS.
  • coraza_rules 'SecRuleEngine On ...';: inline rules straight in the NGINX config, handy for one or two per-location tweaks without a whole file.
  • coraza_transaction_id $some_var;: feed it NGINX’s own request ID so your WAF logs and your access logs share a key. When you’re correlating “what did the WAF do to request X” against your access log at 3 a.m., this is the line that saves you.

A minimal server looks like this:

server {
    coraza on;
    coraza_rules_file /etc/nginx/coraza/coraza.conf;

    location / {
        root /var/www/html;
    }
}

The inheritance is the bit worth memorising. Rules merge parent-then-child: a coraza_rules_file in the server block applies everywhere, and a location block with its own rules gets the parent’s rules prepended to its own. A location with no rules of its own just shares the parent set. So you set the CRS once at the server level and only add per-location overrides where a specific app genuinely needs them. Predictable, top-down, no surprises. The way config inheritance should work and frequently doesn’t.

Coraza WAF vs ModSecurity: which one do you actually want

We package both. libmodsecurity3 with its NGINX connector, and Coraza with this one. They run the same OWASP CRS. So which?

ModSecurity v3 (libmodsecurity) is the battle-tested incumbent. It has been in production on more sites than anyone can count, the CRS is tuned against it first, and every Stack Overflow answer about a weird false positive assumes it. It’s C++, which means it’s fast and it means a parser bug is a memory-safety bug. It’s also in maintenance mode now that ModSecurity proper has been handed off, so the long arc bends away from it.

Coraza is the future-facing pick. Memory-safe by construction, actively developed, designed from day one to be embeddable beyond NGINX. The tradeoff is the cgo/Go-runtime bridge: a slightly heavier per-worker footprint because every worker carries its own copy of the Go runtime. For most sites that cost is invisible. If you’re running a thousand workers on a memory-starved box, measure it.

Here’s my actual opinion, and you can disagree. For a new deployment in 2026, start with Coraza. The memory-safety argument on the single most attacker-exposed component in your stack is the one that wins, and “the CRS is tuned against ModSec first” matters far less than it used to because the CRS team treats Coraza as a first-class target now. Keep ModSecurity if you have years of finely-tuned exclusions you don’t want to revalidate. That tuning is real work and throwing it away to chase a newer engine is its own kind of foolish.

Either way, the WAF is one layer. It is not your security plan. A WAF in front of an unpatched app buys you time, not safety, and anyone who tells you otherwise is selling something.

How we package it, and why only some distros get it

Our build produces a clean dependency chain. libcoraza builds first and publishes to the apt repo: libcoraza1 and libcoraza-dev. Then NGINX builds, with libcoraza-dev as a build dependency (it needs coraza.h to compile the connector) and libcoraza1 as the runtime dependency of the resulting libnginx-mod-http-coraza package. Order matters: if libcoraza isn’t in the repo yet, the NGINX build aborts at dependency resolution before it even starts. Build the library, publish it, then build NGINX. The same applies to our Angie packages, which carry the module as angie-module-http-coraza.

There’s a deliberate limit on which distributions get the module: trixie and resolute only. No bullseye, no bookworm, no jammy, no noble. The reason is that Go 1.25 requirement from earlier. libcoraza needs golang-1.25 to build, and only Debian trixie (with backports, which is what resolute tracks) ships it. Older releases simply don’t have a new enough Go, so the library can’t be built there, so the module can’t exist there. We gate it with Debian build profiles rather than architecture restrictions, because this is a “which release” decision, not a “which CPU” one. On amd64 and arm64 alike, trixie and resolute get Coraza; everything older gets the ModSecurity package and a shrug.

Turning it on with the OWASP CRS

A real config loads the recommended Coraza base config and then the CRS on top. The base config sets the engine on and configures body inspection limits; the CRS brings the actual attack rules. Roughly:

http {
    # one master switch, inherited by every server below
    coraza on;
    coraza_rules_file /etc/nginx/coraza/coraza.conf;
    coraza_rules_file /etc/nginx/coraza/coreruleset/crs-setup.conf;
    coraza_rules_file /etc/nginx/coraza/coreruleset/rules/*.conf;

    server {
        listen 443 ssl;
        server_name example.com;

        location /assets/ {
            coraza off;   # don't run the CRS against static files
        }
    }
}

Start in detection-only mode. The CRS ships with SecRuleEngine DetectionOnly for a reason: turn it loose in blocking mode on a real app on day one and you will block your own admins, your own API, and probably your own healthcheck inside the hour. Run it in detection mode, watch the logs for a week, build your exclusions for the false positives, then flip to SecRuleEngine On. The trailing slash on those location paths matters; yes, it always does; no, nobody is happy about it.

When something does get blocked and you need to know which rule did it, this is where that coraza_transaction_id directive earns its keep. libcoraza 1.6 added the ability to surface the matched rule’s ID through the API, so your logs can tell you “rule 942100 fired” instead of “something somewhere said no”. Wire the transaction ID to NGINX’s $request_id and you can pivot from an access-log line straight to the WAF decision for that exact request. Future you, mid-incident, will be grateful.

If you’ve set up ModSecurity and the CRS on NGINX before, none of this will feel foreign, and our step-by-step ModSecurity and OWASP CRS guide maps almost directly onto Coraza. Swap the engine, keep the rules.

Migrating from ModSecurity to Coraza, step by step

So you already run ModSecurity v3 with the CRS on NGINX, and you want to move to Coraza without taking your site down or throwing away years of tuning. Good news: because both engines speak SecLang and run the same OWASP CRS, this is a config-swap, not a rewrite. The rules come with you. Only the wiring changes. Here is the order I’d do it in, the boring careful way, because the exciting careless way ends with you blocking your own login page at 2 a.m.

Step 0 — Inventory what you already have

Before you touch anything, write down what ModSecurity is currently loading. The three things that matter: your base config (the modsecurity.conf with SecRuleEngine, body limits, audit log settings), your CRS install (which version, the crs-setup.conf, the rules/*.conf), and — the valuable part — your exclusions. Those are the SecRuleRemoveById, ctl:ruleRemoveTargetById, and custom allow rules you bled for over months of false positives. That tuning is the whole reason you’re nervous about migrating. It’s also the part that ports across completely unchanged.

# find what your current modsec setup pulls in
grep -rhE 'Include|modsecurity_rules_file' /etc/nginx/ /etc/modsecurity/ 2>/dev/null
ls -la /etc/modsecurity/ /etc/nginx/modsec*/ 2>/dev/null

Step 1 — Install the Coraza packages alongside, not instead

You do not remove ModSecurity yet. Both modules can be installed at once; you just don’t load both for the same traffic. On trixie or resolute:

apt update
apt install libnginx-mod-http-coraza   # pulls in libcoraza1 automatically
# Angie users:
apt install angie-module-http-coraza

If apt can’t find it, you’re on the wrong release — Coraza is trixie/resolute only, for the Go 1.25 reason covered above. Older boxes stay on ModSecurity; there is no migration target for them yet.

Step 2 — Lay down the Coraza base config and bring your CRS over

Coraza ships a recommended base config (coraza.conf-recommended) that mirrors ModSecurity’s modsecurity.conf-recommended. Copy it, then drop your existing CRS directory in next to it. You do not need to re-download the CRS — the exact files you feed ModSecurity work as-is:

mkdir -p /etc/nginx/coraza
cp /etc/nginx/coraza/coraza.conf-recommended /etc/nginx/coraza/coraza.conf

# reuse the CRS you already have — copy or symlink it in
cp -r /etc/modsecurity/coreruleset /etc/nginx/coraza/coreruleset
cp /etc/nginx/coraza/coreruleset/crs-setup.conf.example \
   /etc/nginx/coraza/coreruleset/crs-setup.conf   # if not already done

This is the part people don’t believe until they see it: your crs-setup.conf, your paranoia-level setting, your anomaly thresholds, all of it carries over byte-for-byte. The CRS doesn’t know or care which engine is reading it.

Step 3 — Port your exclusions (the bit that actually matters)

Your hand-tuned exclusions are SecLang too, so they move across verbatim. If you kept them in a REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf / RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf pair (the CRS-recommended layout), just copy those files in alongside the CRS and load them in the same before/after order. If instead you’d been using our CRS plugins — wordpress-hardening-plugin, vaultwarden-crs-plugin, vimbadmin-crs-plugin — even better: they’re engine-agnostic, so you copy the same plugin directory and load it the same way. No re-tuning, no revalidation. That’s the entire payoff of staying inside the CRS ecosystem instead of inventing your own rules.

Step 4 — Swap the NGINX wiring

This is the only genuinely new part. ModSecurity’s connector used modsecurity on; and modsecurity_rules_file; Coraza uses coraza on; and coraza_rules_file. Same shape, different prefix. Comment out the old directives, add the new ones. Load order is base config → CRS setup → CRS rules → your exclusions, exactly as before:

load_module modules/ngx_http_coraza_module.so;   # in the main context

http {
    # --- old, leave commented until cutover is proven ---
    # modsecurity on;
    # modsecurity_rules_file /etc/nginx/modsec/main.conf;

    # --- new ---
    coraza on;
    coraza_rules_file /etc/nginx/coraza/coraza.conf;
    coraza_rules_file /etc/nginx/coraza/coreruleset/crs-setup.conf;
    coraza_rules_file /etc/nginx/coraza/coreruleset/rules/*.conf;
    coraza_transaction_id $request_id;   # so WAF logs join your access logs
}

Step 5 — Detection-only, for a week, no exceptions

Set SecRuleEngine DetectionOnly in your Coraza base config and reload. Yes, even though you already tuned this exact ruleset under ModSecurity. The engines are not bit-identical: Coraza’s parser and a few operators behave subtly differently on edge cases, so a rule that never fired under ModSec might find something new, and vice versa. Run both engines’ logs side by side for a week and diff the verdicts. If Coraza blocks something ModSecurity let through (or the reverse), that’s your short list to investigate before cutover.

nginx -t && systemctl reload nginx
# watch what Coraza would have done
tail -f /var/log/nginx/error.log | grep -i coraza

Step 6 — Cut over, and keep the rollback one line away

Once the detection-mode logs are clean and match your expectations, flip Coraza to SecRuleEngine On, reload, and watch live for a few hours. The beauty of leaving the old ModSecurity directives commented rather than deleted is your rollback is uncommenting four lines and a reload — under a minute, no reinstall. Once Coraza has run clean in blocking mode for a week or two, then you remove the ModSecurity module and config for good. Not before. There is no prize for deleting the safety net early.

Frequently asked questions

Is Coraza a drop-in replacement for ModSecurity?

Close, but read the fine print. Coraza implements the SecLang rule language and runs the OWASP Core Rule Set unmodified, so your existing SecRule files and CRS setup port over with little or no change. What differs is the engine internals and the integration layer: on NGINX you use the coraza-nginx connector and its four directives instead of the ModSecurity connector. The rules are portable; the wiring is new. Test in detection-only mode before you trust the port.

Why is Coraza written in Go instead of C++?

Memory safety on the most attacker-exposed component in your stack. A WAF parses hostile, attacker-controlled input on the hot path, which is the worst possible place for a buffer overflow or use-after-free. ModSecurity is C++, so a parser bug can be a memory-corruption bug. Go’s garbage collector and bounds checking remove that entire class of vulnerability. The cost is a heavier runtime and the cgo bridge needed to call Go from NGINX’s C.

What is libcoraza and why do I need it separately?

libcoraza is the C-bindings layer that compiles the Go Coraza engine into a C-callable shared library (libcoraza.so.1) using cgo’s c-shared mode. NGINX is C and cannot call Go directly, so libcoraza is the translator. We ship it as two packages: libcoraza1 (the runtime library your server loads) and libcoraza-dev (the header and symlink needed only to build modules against it).

Should I run Coraza in blocking mode right away?

No. Start in detection-only mode (SecRuleEngine DetectionOnly), which is how the CRS ships by default. Run it for at least a week against real traffic, watch your logs for false positives, and build exclusion rules for the legitimate requests it flags. Only after you have tuned out the false positives should you switch to SecRuleEngine On. Going straight to blocking on a live app is the fastest way to block your own admins and API.

Our OWASP CRS plugins

The CRS supports plugins: drop-in rule bundles that tune the base rules for one specific app, killing its false positives and adding app-aware hardening without forking the rule set. They work the same whether the engine underneath is Coraza or ModSecurity. We maintain three:

  • wordpress-hardening-plugin: locks down the usual WordPress attack surface (wp-admin, xmlrpc, REST, login) and clears the CRS false positives the block editor and plugin updates would otherwise trip.
  • vaultwarden-crs-plugin: exclusions for the Vaultwarden / Bitwarden API so the CRS stops flagging legitimate encrypted vault traffic as an attack.
  • vimbadmin-crs-plugin: exclusions for ViMbAdmin, the mail-domain admin panel, so its forms survive a paranoid CRS in blocking mode.

Related reading

Anyway. Before you flip SecRuleEngine On in production, mirror your traffic to a detection-only box for a week. The CRS will block something you depend on. It always does.