Coraza Rules Explained: From SecRule Syntax to Your First Custom Rule
TL;DR: Coraza is an open-source WAF written in Go and the community successor to ModSecurity. Its rule syntax (
SecRule) is almost fully compatible, so migrating an old setup is mostly "swap the engine, keep the rules". The core idea is one SecRule = variable + operator + action, combined with five processing phases and the OWASP CRS rule set: you can adopt the community rules as-is or write your own targeted rules.
1. What Coraza is
Coraza is a Go-based, OWASP-compliant web application firewall engine, and the widely adopted replacement since ModSecurity v3 stopped being maintained. Three points worth calling out:
- ModSecurity-compatible syntax: directives you already wrote -
SecRule,SecAction,SecRuleEngine- and the OWASP CRS (Core Rule Set) run on Coraza with almost no changes, so migration cost is minimal; - Pure Go, no C dependency: it does not depend on the libmodsecurity C library, which makes cross-compilation, containerization and embedding into your own Go program painless;
- Library or gateway: you can
importit into a Go service for inline inspection, or deploy it in front of traffic via a Caddy / Nginx connector or a standalone gateway such as warden.
It is Apache-2.0 licensed and a formal OWASP project.
2. What a rule is made of
Most of Coraza's protection logic lives in a single SecRule. The simplest rule
looks like this:
SecRule REQUEST_HEADERS:User-Agent "@rx (?i)(sqlmap|nikto|nmap)" \
"id:1001,phase:1,deny,status:403,msg:'known scanner UA'"
It has three parts, in fixed order:
| Part | Role | Example above |
|---|---|---|
| Variable | What data to inspect: headers, args, URI, body, IP... | REQUEST_HEADERS:User-Agent |
| Operator | How to decide a match; starts with @ | @rx (?i)(sqlmap|nikto) |
| Action | What to do on match; comma-separated key/value pairs | id:1001,phase:1,deny,... |
The backslash \ is a line continuation, so a long rule can be split across lines for
readability. Let's take the three parts apart.
3. Variables: which piece of data you inspect
Variables decide which part of the request a rule watches. The common ones:
| Variable | Meaning |
|---|---|
REQUEST_URI | Full request path (including query string); most used |
REQUEST_LINE | Full request line, e.g. GET /a?x=1 HTTP/1.1 |
REQUEST_HEADERS | All request headers; add a colon for one, e.g. REQUEST_HEADERS:User-Agent |
REQUEST_BODY | Request body (POST form / JSON / XML; requires the relevant parser on) |
ARGS / ARGS_GET / ARGS_POST | All args / query string only / form only |
QUERY_STRING | Query string only |
REMOTE_ADDR | Client IP |
RESPONSE_BODY | Response body (egress inspection, e.g. leaking card numbers) |
TX | Transaction variable; rules pass data between each other with setvar |
Variables also carry count / collection semantics: &ARGS counts the number of
parameters, and ARGS:username selects only the parameter named username. Multiple
variables are OR-ed with |: REQUEST_HEADERS|REQUEST_BODY means "match if
either header or body hits".
4. Operators: how a match is decided
Operators decide the matching logic and all start with @. The most common is regex matching:
@rx <regular expression> # regex match, returns true on hit
@pm word1 word2 ... # phrase match, multiple keywords OR-ed, faster than many @rx
@pmFromFile /path/list # read keywords from a file for phrase match (e.g. scanner UA list)
Other common operators:
| Operator | Test |
|---|---|
@eq / @gt / @lt / @ge / @le | equal / greater / less / greater-or-equal / less-or-equal (numeric) |
@contains / @beginsWith / @endsWith | contains / prefix / suffix |
@within | whether the target is within a given set (e.g. IP within a CIDR) |
@ipMatch | whether the client IP matches a CIDR / IP list |
@validateUrlEncoding | whether URL encoding is valid (catches %u and other malformed-encoding bypasses) |
@validateUtf8Encoding | whether UTF-8 encoding is valid |
@detectSQLi | built-in SQL injection detection (used by CRS) |
@detectXSS | built-in XSS detection |
@rx with ! | negation: @rx !... matches when it does NOT match |
Tip: @pm is much faster than a chain of @rx (a|b|c) because it uses an
internal Aho-Corasick multi-pattern matcher; when matching dozens of scanner keywords, prefer
@pmFromFile.
5. Transforms: normalize before matching
Attackers mix case, double-URL-encode, and pad with whitespace to evade rules. A transform runs
before matching to normalize those variations. It is written in the action list with a
t: prefix and several can be stacked:
"id:1002,phase:2,deny,t:lowercase,t:urlDecode,t:removeWhitespace,t:compressWhitespace,@rx (?i)(union\s+select|drop\s+table)"
| Transform | What it does |
|---|---|
t:none | Clears any previously accumulated transforms (usually placed first to reset) |
t:lowercase | Lowercases, defeating case evasion |
t:urlDecode / t:urlDecodeUni | URL-decode (including %u encoding) |
t:removeWhitespace / t:compressWhitespace | Remove / compress runs of whitespace |
t:htmlEntityDecode | Decode entities like & < |
t:base64Decode | Base64-decode |
t:normalisePath / t:normalisePathWin | Normalize path (resolve ../ and extra slashes) |
t:cmdLine | Normalize a command-line string (defeats spacing, quotes, path tricks) |
6. Actions: what happens after a match
Actions fall into three groups. The disruptive actions decide the request's fate:
| Disruptive | Effect |
|---|---|
deny | Block immediately; pair with status:403 (or 406, etc.) |
block | Block per the current SecDefaultAction (more flexible) |
pass | Allow but log / count (common for alert-only rules) |
allow | Allow and skip remaining phase checks |
redirect + location | 302 redirect to a given URL |
Non-disruptive actions keep bookkeeping and context:
setvar:tx.sql_hits=+1 # increment a tx variable (block later when > threshold)
setvar:tx.block_flag=1 # set a flag later rules read to block
capture # store @rx capture groups into TX.0 / TX.1 ...
log / nolog # whether to write to the log
auditlog / noauditlog # whether to enter the audit log
Some metadata actions must be on every rule for troubleshooting:
id:1003 # rule ID (required, globally unique; CRS uses 900000+, custom 1000-7999)
phase:2 # processing phase
msg:'sql injection' # human-readable note recorded on match
severity:'CRITICAL' # level (EMERGENCY/ALERT/CRITICAL/ERROR/WARNING/NOTICE/INFO)
tag:'attack-sqli' # tag, handy for grouping stats
7. Chains: multi-condition "AND"
A single rule expresses one "variable + operator". To express "A AND B", chain rules with
chain; only when the whole chain matches does the last rule's disruptive action run:
SecRule ARGS_GET:q "@rx (?i)select" "id:2001,phase:2,chain,t:none,t:lowercase"
SecRule REQUEST_HEADERS:User-Agent "@rx (?i)(sqlmap|havij)" "deny,status:403,msg:'sql tool'"
This means: block only when parameter q contains select and the UA is a
scanner - avoiding a bare "select" blocking a legitimate search that happens to contain SQL
keywords.
8. The five phases
Rules are distributed across request / response phases by phase; earlier is cheaper:
| Phase | When | Good for |
|---|---|---|
| phase:1 | Request headers just received | Coarse filter by IP / UA / Host (fastest) |
| phase:2 | Request body parsed | Injection / XSS on args and body (most used) |
| phase:3 | Before response headers | Egress header rewriting |
| phase:4 | After response body | Egress data-leak inspection (card / ID numbers) |
| phase:5 | At logging | Stats / logging only, never blocks |
Putting cheap coarse filters in phase:1 and expensive regex / decoding in phase:2 is the key to fewer false positives and lower overhead.
9. OWASP CRS: rules that work out of the box
Writing your own rules is the backstop; what actually stops day-to-day attacks is the OWASP CRS (Core Rule Set) - a community-maintained, general-purpose rule set covering SQLi, XSS, file inclusion, protocol violations and scanner fingerprints, shipped with Coraza. You enable it by Including it in your config:
Include /path/to/coraza.conf # engine base config (SecRuleEngine, etc.)
Include /path/to/crs-setup.conf # CRS master switch and tuning
Include /path/to/rules/*.conf # the actual rule files
Handy CRS knobs (in crs-setup.conf):
tx.paranoia_level: paranoia level 1-4; higher is stricter and noisier, default 1. Start at 1 and raise it once things are stable;tx.blocking_paranoia_level: the level that actually blocks; can be lower thanparanoia_levelso higher levels only log while lower levels block;tx.anomaly_score_block: block when the accumulated anomaly score passes a threshold instead of on a single hit - far more stable than per-rule deny; each rule usually only adds score, and the total decides, which sharply cuts false positives.
10. Hands-on: a custom anti-SQLi rule
Suppose your search endpoint /search?q= keeps getting injection probes and you want a
targeted rule on top of CRS. Idea: normalize first, regex next, count rather than block on hit, and
deny only past a threshold:
# /etc/coraza/custom/search-sqli.conf
SecRule REQUEST_URI "@rx (?i)/search" "id:900100,phase:1,pass,nolog,setvar:tx.on_search=1"
SecRule ARGS_GET:q \
"@rx (?i)(union\s+select|select\s+.*\s+from|or\s+1=1|'\s+or\s+'|drop\s+table|insert\s+into)" \
"id:900101,phase:2,chain,t:none,t:lowercase,t:urlDecode,t:compressWhitespace"
SecRule TX:on_search "@eq 1" \
"deny,status:403,msg:'SQLi in search q',severity:'CRITICAL',tag:'attack-sqli',\
setvar:tx.sql_score=+5"
SecAction "id:900102,phase:2,pass,setvar:tx.sql_score=0"
SecRule TX:sql_score "@ge 5" "id:900103,phase:2,deny,status:403,msg:'SQLi score exceeded'"
This rule does three things: 1) it only applies on /search, leaving other endpoints
alone; 2) it matches q case-insensitively and after decoding, and on a hit just stamps
a sql_score flag; 3) the real block is the "score reached 5" rule, leaving a buffer for
legitimate keyword searches so one bad match does not 403. Include the custom file in the main
config; CRS itself stays untouched.
11. DetectionOnly vs On: observe before you block
SecRuleEngine is the master switch; only two values matter:
| Value | Behavior | When |
|---|---|---|
DetectionOnly | Log only, never block | Run new rules / new sites for a while first |
On | Act on matches per the actions | Once false positives are under control |
Rule of thumb: any new rule, any newly onboarded site, runs in DetectionOnly for at least one
to two weeks first. Read the audit log to see whether legitimate traffic was flagged (typical
false positives: searches containing "select", rich-text editing containing <, or
large JSON blobs of text). Once the false-positive rate is acceptable, switch the relevant rules to
On or raise blocking_paranoia_level. Going straight to On is the number
one cause of a WAF blocking legitimate traffic.
12. How to actually run it
Coraza is not only a standalone box; four common ways to deploy:
| Way | How | When |
|---|---|---|
| Go library inline | import github.com/corazawaf/coraza/v3 and call ProcessRequest in your handler | You write the Go service and want inline inspection |
| Caddy connector | Use the coraza-caddy plugin; a few lines in the Caddyfile | You already reverse-proxy with Caddy |
| Nginx connector | coraza-nginx dynamic module | You already use Nginx and want minimal change |
| Standalone gateway | E.g. warden, one binary packing Coraza + CC protection + dashboard | You want a panel and zero config fiddling |
For Caddy, the minimal config is just:
{
order coraza before reverse_proxy
}
example.com {
coraza {
directives `
Include /etc/coraza/coraza.conf
Include /etc/coraza/crs/crs-setup.conf
Include /etc/coraza/rules/*.conf
`
}
reverse_proxy 127.0.0.1:8000
}
13. Recap
Coraza brings ModSecurity's mature rule system to Go, at near-zero migration cost. Keep this spine in mind:
| Concept | One line |
|---|---|
| One rule | variable + operator (starts with @) + action (id/phase/deny...) |
| Normalize | Use t: transforms to defeat evasion before matching |
| Multi-condition | chain for "AND", TX variable for cross-rule counting |
| Daily protection | Adopt OWASP CRS; do not hand-roll from scratch |
| Go-live discipline | DetectionOnly observe -> then On; score-threshold blocking beats per-rule deny |
To see a real project that packs Coraza with CC protection and IP-origin blocking into a single-file gateway, read warden: A Single-Binary Go Reverse-Proxy WAF.