|8 min read|Yvann Lièvre

Open-Source WAF: Protecting Web Apps with OWASP CRS (Coraza and ModSecurity)

Deploy an open-source WAF (Coraza, ModSecurity) with OWASP CRS: paranoia levels, endpoint-scoped exclusions, and tuning to cut CRS false positives for SMBs.

WAFOWASP CRSApplication Security
Open-Source WAF: Protecting Web Apps with OWASP CRS (Coraza and ModSecurity)

Every SMB running a public web application eventually asks the same question: put a US-based SaaS WAF in front of it, or is there a credible, self-hosted alternative that keeps control in-house? The open-source ecosystem has answered that for years, with OWASP Core Rule Set (CRS) as the reference rule engine. The real question is not whether an open-source WAF can block a SQL injection (it can, easily), it is whether you can tune it without drowning in false positives during the first week in production.

Why put an HTTP WAF in front of your apps

A Web Application Firewall inspects HTTP requests before they reach application code. Three attack families get stopped at this layer, before the request ever touches business logic:

  • SQL injection: a request carrying UNION SELECT, OR 1=1, or timing functions like SLEEP() and BENCHMARK() is caught by its shape, regardless of the framework running behind it.
  • Cross-Site Scripting (XSS): <script> tags, event handlers (onerror=, onload=), or suspicious encodings in parameters are flagged before they get reflected into a page.
  • Path traversal: sequences like ../../../etc/passwd or attempts to reach sensitive paths are filtered at the URL layer, independent of the (sometimes missing) validation in application code.

The value of filtering upstream is that it also protects code you have not had time to fix yet: a vulnerable dependency, a legacy endpoint, a library that will only get patched next month. A WAF does not replace fixing the code, it buys time while technical debt gets paid down.

Coraza or ModSecurity: two engines, one rule language

Two open-source engines run OWASP CRS today.

ModSecurity is the historical engine, written in C, shipped as an Apache module and later an NGINX connector. It has a fifteen-year track record, but its trajectory is clear: the NGINX connector is no longer under active maintenance, and the project now sits in maintenance mode heading toward end of life. Building a new deployment on it today means building on a foundation that will not see major evolution going forward.

Coraza is the engine on the rise: written in Go, cloud-native from the ground up, packaged as a library, an NGINX module, or a WASM proxy filter. Its central selling point is 100 percent SecLang compatibility with ModSecurity: OWASP CRS v4 rules run unchanged, no translation or rewriting needed. Moving from ModSecurity to Coraza does not mean learning a new rule language, it means swapping execution engines under an unchanged ruleset.

# docker-compose.yml (excerpt): Coraza as a sidecar in front of an app
services:
  waf:
    image: caddy:2-alpine
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./crs:/etc/crs
    ports:
      - "443:443"
  app:
    image: myapp:latest
    expose:
      - "8080"

For an SMB building today, Coraza is the sensible default: same CRS rule coverage, an actively developed engine, and a lighter runtime footprint.

CRS paranoia levels: the security-versus-false-positives dial

OWASP CRS exposes four paranoia levels (PL1 to PL4). The higher the level, the more rules fire, and the higher the false-positive rate climbs alongside it:

  • PL1: broad coverage of obvious attacks, very low false-positive rate. This is the recommended starting level for any production rollout.
  • PL2: additional rules against subtler evasion techniques. False positives get more frequent on applications that legitimately handle content close to attack patterns (rich text forms, WYSIWYG editors).
  • PL3 and PL4: maximum-hardening levels, reserved for highly sensitive scopes, with a proportionally heavy tuning budget behind them.

CRS works on anomaly scoring: every rule that matches adds points to a cumulative score for the request, and blocking only kicks in once that score crosses a configured threshold. Understanding this changes how you think about false positives: the goal is not preventing a rule from matching, it is preventing that match from counting against one specific legitimate request.

# crs-setup.conf (excerpt)
SecAction \
 "id:900000,\
  phase:1,\
  pass,\
  t:none,\
  setvar:tx.paranoia_level=1,\
  setvar:tx.inbound_anomaly_score_threshold=5,\
  setvar:tx.outbound_anomaly_score_threshold=4"

Start at PL1 with the default thresholds, measure against real traffic for two to four weeks, then raise the level gradually if the context requires it. That sequencing is what keeps day one from turning into a false-positive storm.

The central trap: exclude a rule without disarming the whole CRS

This is the single most common mistake, and the costliest one from a security standpoint. A contact form throws a false positive on an XSS rule because a "comments" field legitimately contains < or > characters. The instinctive fix is to disable the rule globally:

# DO NOT DO THIS: disables the rule for EVERY request, across every endpoint
SecRuleRemoveById 941100

That opens a security gap across the entire perimeter to fix a problem confined to a single endpoint and a single parameter. The correct approach is a targeted exclusion with SecRuleUpdateTargetById, which removes one specific parameter from a rule's inspection scope without touching the rest of the traffic:

# DO THIS INSTEAD: rule 941100 stays active everywhere except this one parameter
SecRuleUpdateTargetById 941100 "!ARGS:comment"

Another useful variant is a path-scoped exclusion, when the false positive is tied to a whole endpoint rather than a single parameter:

<LocationMatch "/api/v1/rich-text-editor">
    SecRuleRemoveById 941100 941160
</LocationMatch>

The gap between these two approaches is the gap between a tuned WAF and a bypassed one. Every exclusion should be documented (which rule, which endpoint, which parameter, why), version-controlled in the configuration repository, and reviewed periodically: an exclusion forgotten for three years on an endpoint whose purpose has since changed is a backdoor that never announces itself.

Deploying the WAF as code

An open-source WAF only pays off if it is wired into the delivery pipeline like any other application component. Concretely:

  • Placement: in front of NGINX (native module), in front of Envoy (WASM filter), or as a Kubernetes Ingress (Coraza integrates natively as an Ingress Controller). Infrastructure should drive the choice, not the other way around.
  • WAF-as-code: the CRS ruleset, paranoia thresholds, and exclusions live in a Git repository, with code review on every change, exactly like a network firewall policy.
  • Test before production: every rule change gets replayed against a corpus of legitimate traffic captured in production (real requests, anonymized) before any deployment. That is the only way to prove a change does not break real users, rather than finding out afterward through support tickets.
# Example: regression test before deploying a rule change
coraza-testsuite run --ruleset ./crs --corpus ./traffic-samples/legitimate.har \
  --fail-on-block

A WAF that blocks silently, with no one replaying changes against real traffic, always ends up disabled in a hurry on a Friday night, wiping out the entire benefit it was supposed to deliver.

Wrapping up

An open-source WAF built on Coraza (or ModSecurity in transition) and OWASP CRS delivers real protection against SQL injection, XSS, and path traversal, with no dependency on a SaaS vendor. The engine is not the hard part, the method is: start at PL1 with anomaly scoring, target exclusions rule by rule and endpoint by endpoint instead of disabling globally, and treat the configuration as code that gets tested before every production release.

That is exactly the tuning work we ship ready to use in the hardened WAF rule pack: exclusions already validated against corpora of legitimate traffic, to cut false-positive load without reopening the door that CRS was meant to close.

Related articles