Open-source project
corazawaf/coraza-caddy avatar
corazawaf/coraza-caddy

coraza-caddy puts a Coraza WAF inside the Caddy request path

OWASP Coraza middleware for Caddy. It provides Web Application Firewall capabilities

684 stars74 forksGoApache-2.0

At a glance

What is it?
coraza-caddy is a Caddy module that runs OWASP Coraza as a web application firewall in front of Caddy's handlers, so ModSecurity rules and the OWASP Core Ruleset apply to a Caddyfile deployment. The Core Ruleset is compiled into the binary, and the module is built with xcaddy rather than installed as a plugin.
Who is it for?
coraza-caddy suits a team that already runs Caddy and wants a ModSecurity-style WAF without running a second proxy in front of it, since the module filters the request inside the Caddy pipeline and the Core Ruleset ships inside the binary, so there is nothing to mount or download.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 4 days ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 20, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A WAF as a Caddy handler, not a proxy in front

coraza-caddy is a Caddy module that provides Web Application Firewall capabilities for Caddy, using OWASP Coraza as the engine. The README states that Coraza is 100% compatible with OWASP Coreruleset and Modsecurity syntax.

The architectural choice is what makes it different from the usual WAF deployment. Rather than putting a WAF proxy in front of the web server, the filtering happens inside the Caddy request pipeline, as a handler. There is no extra network hop, no extra TLS termination and no second thing to keep alive.

The Caddyfile syntax is a coraza_waf block whose directives hold ModSecurity directives:

caddy
coraza_waf {
 directives `
  Include /path/to/config.conf
  SecAction "id:1,pass,log"
 `
}

The rules are ModSecurity rules, which means the corpus of existing rules and the OWASP Core Ruleset apply unchanged.

One line is mandatory, and the README flags it as Important: order coraza_waf first must be always included in your Caddyfile for the Coraza module to work. Caddy's handler ordering is configurable, and the WAF has to run before the handler it is protecting, so the ordering directive is what puts it in the right place. A sample configuration shows the shape:

caddy
{
    order coraza_waf first
}

http://127.0.0.1:8080 {
 coraza_waf {
  directives `
   SecAction "id:1,pass,log"
   SecRule REQUEST_URI "/test5" "id:2, deny, log, phase:1"
   SecRule REQUEST_URI "/test6" "id:4, deny, log, phase:3"
   Include file1.conf 
   Include file2.conf
   Include /some/path/*.conf
  `
 }
 reverse_proxy http://192.168.1.15:8080
}

The phase:1 and phase:3 in the two SecRule lines are the ModSecurity request and response phases, so the same block can both inspect and block.

The Core Ruleset is compiled into the binary

The most operationally interesting decision in the module is how the OWASP Core Ruleset is delivered, and the README is unusually explicit about the consequences.

You can load OWASP CRS by passing the field load_owasp_crs and then load the CRS files in the directives. The @-prefixed paths are not files on disk. They are served from a ruleset that is compiled into the binary, and load_owasp_crs is what makes them resolvable.

That has two consequences. You do not need to download CRS, mount a volume, or ship rule files alongside the binary, because the xcaddy build already contains them. And you must not put a filesystem path in front of an @ path: Include /etc/caddy/rules/@owasp_crs/*.conf will not work, and the @ prefix must start the path.

The failure mode when you get that wrong is specific and the README shows it. If you use an @ path without load_owasp_crs, Caddy fails at startup with:

text
invalid WAF config from string: failed to readfile:
  open @coraza.conf-recommended: no such file or directory

Adding load_owasp_crs to the coraza_waf block resolves it, and the full configuration becomes:

caddy
:8080 {
 coraza_waf {
  load_owasp_crs
  directives `
   Include @coraza.conf-recommended
   Include @crs-setup.conf.example
   Include @owasp_crs/*.conf
   SecRuleEngine On
  `
 }

 reverse_proxy httpbin:8081
}

So there is a real trade here. Embedding the ruleset means a self-contained binary with no runtime file dependencies, which is the right shape for a container. The cost is that updating the ruleset means rebuilding, since there is nothing on disk to replace.

Building it, and the developer command set

The module is built with xcaddy rather than installed into a stock Caddy binary:

shell
xcaddy build --with github.com/corazawaf/coraza-caddy/v2

That command produces a Caddy binary with the module linked in, and because the Core Ruleset is compiled in at the same time, the resulting binary is the whole deployment.

The repository uses mage for its own development tasks, and the list of targets is printed by go run mage.go -l, which shows the whole developer surface: buildCaddy builds the plugin, buildCaddyLinux builds it with GOOS=linux, buildExample builds the example deployment, check runs lint and tests, coverage runs tests with coverage and the race detector enabled, doc runs godoc on http://localhost:6060, e2e runs end to end tests with a built plugin against the example deployment, format formats the code, ftw runs the CRS regression tests, lint verifies code quality, precommit installs a git hook, reloadExample reloads the test environment, runExample spins it up on http://localhost:8080, teardownExample tears it down, and test runs all the tests.

The ftw target deserves a note. CRS regression tests are the way the ruleset is validated against a corpus of known attack and benign traffic, and running them as part of the module's own test suite is what catches the case where a Caddy integration change breaks a rule that used to work.

The test suite itself is a single command:

shell
go run mage.go test

The repository has a TROUBLESHOOTING.md alongside the README, and the go.mod pins Caddy at v2.11.4, Coraza at v3.8.1 and the CRS at v4.30.0, with the module path on the v2 major version and a Go 1.26.0 requirement.

Coraza plugins and the local module trick

The module supports Coraza plugins, and the README explains the packaging problem they create.

Coraza plugins, which are extra operators, body processors and GeoIP lookups, are plain Go packages that register with Coraza from an init function. xcaddy adds a blank import for every --with package, so a plugin that registers itself and needs no configuration can be added straight to the build:

shell
xcaddy build --with github.com/corazawaf/coraza-caddy/v2 \
  --with github.com/some/coraza-plugin

The complication is a plugin that needs setup, such as coraza-geoip, which must be given a database. That needs a small package of your own that does the registration, and the README gives the shape:

go
// register/register.go
package register

import (
	_ "embed"
	"fmt"

	geo "github.com/corazawaf/coraza-geoip"
)

//go:embed GeoLite2-Country.mmdb
var db []byte

func init() {
	if err := geo.RegisterGeoDatabase(db, "country"); err != nil {
		panic(fmt.Errorf("registering GeoIP database: %w", err))
	}
}

The embed directive is the interesting part. The GeoLite2 database is compiled into your binary through the embed directive, so the plugin does not need a path to a file at run time, and the init function panics if registration fails, which means a misconfigured build fails at startup rather than at the first request that happens to use a GeoIP rule.

That package is then passed to xcaddy as a local module:

shell
# register/go.mod declares `module example.com/register` and requires coraza-geoip
xcaddy build --with github.com/corazawaf/coraza-caddy/v2 \
  --with example.com/register=./register

The trade is the usual one for a WAF plugin that needs a database. Embedding keeps the binary self-contained, and it also means the database is in the binary, so updating it means rebuilding, and the licence terms of whatever database you embed come with it.

The transaction ID header, and the spoofing risk it carries

One feature deserves its own attention because it comes with a security condition attached.

You can specify the transaction ID by setting the field tx_id_req_header and then passing the value for that request header. This is useful when running coraza behind another HTTP server and using the request ID issued by that server.

The important part is the constraint. tx_id_req_header must be set or overwritten only by a trusted upstream proxy, and that proxy must strip any client-provided value for the same header before it reaches coraza. Otherwise, clients can spoof the transaction ID, and any downstream consumers of it.

That is a real risk and it is not hypothetical. A transaction ID is the kind of value that ends up in log lines, in audit trails and in correlation across services. If a client can choose it, then a client can make two different requests look like the same transaction, or make two requests look like different transactions, and anything reading those identifiers inherits the confusion.

The configuration form is otherwise unremarkable:

caddy
:8080 {
 coraza_waf {
  load_owasp_crs
  directives `
   Include @coraza.conf-recommended
   Include @crs-setup.conf.example
   Include @owasp_crs/*.conf
   SecRuleEngine On
  `
  tx_id_req_header "X-Transaction-ID"
 }

 reverse_proxy httpbin:8081
}

The operational takeaway is that this option is only safe behind a proxy you control, and that the proxy has to be configured to remove the header before injecting its own.

Memoization, and the tag that changed meaning

The Performance section is short and describes a change in defaults that will confuse anyone following older documentation.

Coraza memoizes the expensive parts of rule compilation, the regex and the aho-corasick pattern compilation, so identical patterns are not recompiled when several WAF instances in the same process share the same rules. The README says this matters most when one Caddy process serves many sites that each load CRS.

That is a sensible optimisation and a common deployment shape. A Caddy instance fronting a hundred sites, each with the Core Ruleset loaded, would otherwise compile the same thousands of patterns a hundred times at startup.

Memoization is enabled by default. There is no flag to turn it on, and the coraza.no_memoize build tag exists only to turn it off:

| Build tag | Behaviour | |---|---| | *(none)* | Memoization enabled (default) | | `coraza.no_memoize` | Disabled, every pattern compiles fresh |

The trap is in the last paragraph, which the README cuts off. Older guidance recommends building with -tags memoize_builders. That tag was how memoization was opted into up to Coraza v3.4.0, when the default was off. It was removed in v3.5.0.

So a build script carried over from an older setup now passes a tag that does nothing, and the operator may believe they have disabled memoization when they have in fact enabled it. Since the go.mod pins Coraza at v3.8.1, that is well past the version where the tag was removed.

Where coraza-caddy fits, and where it stops

The comparison worth making is with the other ways to put a WAF in front of an application.

Against a separate WAF appliance or container, the difference is the number of moving parts. Here the filter runs inside the Caddy process that is already terminating TLS and routing requests, so there is no extra network hop and no separate thing to deploy, monitor and patch. For a team already running Caddy as its only edge, that removes a whole layer of the deployment.

Against a ModSecurity-backed Apache or Nginx, the difference is the ecosystem rather than the rules. The rules are the same, and the CRS is the same, so the security posture of the ruleset does not change. What changes is the web server, and with it the configuration language, the module system and the operational tooling.

The limits are structural. The module is not a package you install into a stock Caddy binary; it is built through xcaddy, so your build pipeline and your distribution of Caddy change. The Core Ruleset is compiled into the binary, so updating the ruleset means rebuilding rather than replacing files. The plugin mechanism requires writing a wrapper package for anything that needs configuration, and a plugin that embeds a database carries that database's licensing into your artefact.

The tx_id_req_header option is a genuine footgun if the deployment is misconfigured, and the README says so plainly, which is a point in the project's favour.

On the maintenance side, the release cadence is regular, with v2.6.2 on 2026-10-08 after v2.6.1 in September and v2.6.0 in August, and the repository carries a repostatus badge marking the project as active and being actively developed. The licence is Apache-2.0, and the repository also has a CHANGELOG.md and a renovate.json for dependency updates.

Editorial conclusion

coraza-caddy suits a team that already runs Caddy and wants a ModSecurity-style WAF without running a second proxy in front of it, since the module filters the request inside the Caddy pipeline and the Core Ruleset ships inside the binary, so there is nothing to mount or download. It is the wrong tool if you need Caddy's standard distribution binaries, because the module is built through xcaddy and is not a drop-in package, and it is the wrong tool if you need a WAF at the network edge serving clients that never reach Caddy. Before adopting it, put order coraza_waf first at the top of the Caddyfile, and if you run it behind another proxy, configure tx_id_req_header only on a proxy that strips the client value.

Frequently asked questions

How do I build Caddy with the Coraza WAF module?

Run xcaddy build --with github.com/corazawaf/coraza-caddy/v2. The resulting binary has the module and the OWASP Core Ruleset compiled in, so there are no rule files to download or mount alongside it.

What does the coraza_waf Caddyfile block look like?

It holds a directives block containing ModSecurity directives, for example coraza_waf { directives ` SecAction "id:1,pass,log" ` }, and the Caddyfile must also contain order coraza_waf first at the top level, which the README marks as required for the module to work.

How do I load the OWASP Core Ruleset in coraza-caddy?

Add load_owasp_crs to the coraza_waf block and then include the @-prefixed paths, such as Include @coraza.conf-recommended, Include @crs-setup.conf.example and Include @owasp_crs/*.conf, with SecRuleEngine On. Without load_owasp_crs, Caddy fails at startup with invalid WAF config from string and a failed to readfile error.

How do I add a Coraza plugin to the Caddy build?

Pass it as another --with argument to xcaddy, and a plugin that registers itself from an init function needs no extra work. A plugin that needs setup, such as coraza-geoip with a database, requires a small package of your own that does the registration and that you pass as a local module with --with example.com/register=./register.

What is the security requirement for tx_id_req_header?

The field must be set or overwritten only by a trusted upstream proxy, and that proxy must strip any client-provided value for the same header before it reaches coraza. Otherwise clients can spoof the transaction ID and any downstream consumers of it.

Official sources

  1. corazawaf/coraza-caddy on GitHub
  2. License: Apache-2.0
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/corazawaf-coraza-caddy.svg)](https://hysenlabs.com/projects/corazawaf-coraza-caddy)

Was this useful?