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 import it 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:

PartRoleExample above
VariableWhat data to inspect: headers, args, URI, body, IP...REQUEST_HEADERS:User-Agent
OperatorHow to decide a match; starts with @@rx (?i)(sqlmap|nikto)
ActionWhat to do on match; comma-separated key/value pairsid: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:

VariableMeaning
REQUEST_URIFull request path (including query string); most used
REQUEST_LINEFull request line, e.g. GET /a?x=1 HTTP/1.1
REQUEST_HEADERSAll request headers; add a colon for one, e.g. REQUEST_HEADERS:User-Agent
REQUEST_BODYRequest body (POST form / JSON / XML; requires the relevant parser on)
ARGS / ARGS_GET / ARGS_POSTAll args / query string only / form only
QUERY_STRINGQuery string only
REMOTE_ADDRClient IP
RESPONSE_BODYResponse body (egress inspection, e.g. leaking card numbers)
TXTransaction 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:

OperatorTest
@eq / @gt / @lt / @ge / @leequal / greater / less / greater-or-equal / less-or-equal (numeric)
@contains / @beginsWith / @endsWithcontains / prefix / suffix
@withinwhether the target is within a given set (e.g. IP within a CIDR)
@ipMatchwhether the client IP matches a CIDR / IP list
@validateUrlEncodingwhether URL encoding is valid (catches %u and other malformed-encoding bypasses)
@validateUtf8Encodingwhether UTF-8 encoding is valid
@detectSQLibuilt-in SQL injection detection (used by CRS)
@detectXSSbuilt-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)"
TransformWhat it does
t:noneClears any previously accumulated transforms (usually placed first to reset)
t:lowercaseLowercases, defeating case evasion
t:urlDecode / t:urlDecodeUniURL-decode (including %u encoding)
t:removeWhitespace / t:compressWhitespaceRemove / compress runs of whitespace
t:htmlEntityDecodeDecode entities like &amp; &#x3c;
t:base64DecodeBase64-decode
t:normalisePath / t:normalisePathWinNormalize path (resolve ../ and extra slashes)
t:cmdLineNormalize 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:

DisruptiveEffect
denyBlock immediately; pair with status:403 (or 406, etc.)
blockBlock per the current SecDefaultAction (more flexible)
passAllow but log / count (common for alert-only rules)
allowAllow and skip remaining phase checks
redirect + location302 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:

PhaseWhenGood for
phase:1Request headers just receivedCoarse filter by IP / UA / Host (fastest)
phase:2Request body parsedInjection / XSS on args and body (most used)
phase:3Before response headersEgress header rewriting
phase:4After response bodyEgress data-leak inspection (card / ID numbers)
phase:5At loggingStats / 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 than paranoia_level so 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:

ValueBehaviorWhen
DetectionOnlyLog only, never blockRun new rules / new sites for a while first
OnAct on matches per the actionsOnce 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:

WayHowWhen
Go library inlineimport github.com/corazawaf/coraza/v3 and call ProcessRequest in your handlerYou write the Go service and want inline inspection
Caddy connectorUse the coraza-caddy plugin; a few lines in the CaddyfileYou already reverse-proxy with Caddy
Nginx connectorcoraza-nginx dynamic moduleYou already use Nginx and want minimal change
Standalone gatewayE.g. warden, one binary packing Coraza + CC protection + dashboardYou 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:

ConceptOne line
One rulevariable + operator (starts with @) + action (id/phase/deny...)
NormalizeUse t: transforms to defeat evasion before matching
Multi-conditionchain for "AND", TX variable for cross-rule counting
Daily protectionAdopt OWASP CRS; do not hand-roll from scratch
Go-live disciplineDetectionOnly 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.

Further reading

Feedback

This site is front-end only — no backend, no accounts — so feedback goes to GitHub Issues, Discussions, or email.

Paste it into the issue or email to help track it down — it never includes anything you typed

Also: Browse existing issues · Email feedback (no GitHub account needed): 278975598@qq.com