If you run a WAF backed by ModSecurity or Coraza (the Go rewrite most modern setups use now — nginx, HAProxy via SPOE, Traefik plugins, etc.), sooner or later you write a custom SecRule by hand. The syntax is powerful and genuinely well-designed once it clicks, but the learning curve is real — most people's first custom rule either silently does nothing or blocks traffic they didn't mean to.
Here's the anatomy of a rule, and the specific mistakes that actually trip people up.
The shape of a SecRule
Every rule is three parts: what to look at, how to match it, what to do.
SecRule VARIABLE "OPERATOR value" "ACTIONS"
A real one — block any request whose URI contains /wp-admin from an IP not in your office range:
SecRule REQUEST_URI "@contains /wp-admin" "id:1000001,phase:2,deny,status:403,msg:'Blocked wp-admin probe'"
-
REQUEST_URI— the variable being inspected. Others you'll reach for constantly:ARGS(any query/body parameter),REQUEST_HEADERS,REQUEST_COOKIES,REQUEST_METHOD,REMOTE_ADDR. -
@contains— the operator.@streq(exact match),@beginsWith/@endsWith,@rx(regex),@ipMatch(IP/CIDR), and the built-in@detectSQLi/@detectXSSheuristics are the ones you'll use most. -
id,phase,deny,status,msg— actions. Every rule needs a uniqueid.phase:2means "after the request body is parsed" — almost always what you want for anything inspectingARGSorREQUEST_BODY;phase:1runs before that, headers/URI only.
Combining conditions
Multiple SecRule lines chained with chain act as AND — all of them have to match. The gotcha: only the first line carries the real actions (id, phase, deny, etc.); every line after it just needs chain (except the last, which needs nothing extra):
SecRule REQUEST_METHOD "@streq POST" "id:1000002,phase:2,deny,status:403,chain"
SecRule REQUEST_URI "@contains /api/admin" "t:none"
Forget the chain on a non-final line and you've written two independent rules instead of one AND condition — this is the single most common way a "working" rule turns out to match far more (or far less) than intended.
Allow-listing instead of blocking
The other half of writing WAF rules is un-blocking something the ruleset got wrong — a legitimate rich-text field that trips an XSS heuristic, for example. Don't just disable the whole rule globally; scope the exemption as narrowly as you can:
SecRule REQUEST_URI "@beginsWith /editor/save" "id:1000003,phase:2,pass,nolog,ctl:ruleRemoveTargetById=941320;ARGS:content"
ctl:ruleRemoveTargetById=<id>;<VARIABLE> removes just one field from one rule's inspection
— rule 941320 keeps checking everything else on this request, it just stops looking at ARGS:content. Use ctl:ruleRemoveById=<id> (no target) only when you're sure the whole rule should be off for requests matching your condition — that's a much bigger exemption than most false-positive fixes actually need.
The mistake almost everyone makes once: rule ID collisions
If you're running the OWASP Core Rule Set (CRS) — most Coraza/ModSecurity setups are — its rules live in id range 900000–999999 (and CRS reserves 800000–999999 broadly for its own use). Pick a custom rule ID in that range and you'll get silent, confusing collisions the first time CRS ships an update that happens to use the same number.
Use 1000000 and up for anything custom. This is genuinely the most common mistake I see — it works fine until the day it doesn't.
Case sensitivity and transformations
t:lowercase before a @streq/@contains match makes it case-insensitive; the default (t:none) is case-sensitive. Easy to forget, and the failure mode is quiet — the rule just doesn't fire on /WP-ADMIN instead of erroring, which is exactly the kind of gap that gets found in production instead of testing.
Building these without the syntax fights
Once you know the shape, hand-writing simple rules is fine — but the escaping rules (regex needs different quote-escaping than a literal string match), the chain/action ordering, and the CRS reserved-ID range are exactly the kind of thing that's easy to get subtly wrong under time pressure, mid-incident, at 2am.
I built a small free tool that generates a correct rule from plain fields — pick the variable, operator, and value, and it handles the syntax, the chaining, the reserved-ID warning, and the block-vs-exempt distinction above for you:
WAF Rule Builder — runs entirely in your browser, nothing you type is sent anywhere, just copy the generated rule into your own config.
(Disclosure: I built this — it's a free tool from ShieldIngress, a WAF/edge security product I run. No catch, no signup — I'd just rather people not lose an evening to a missing chain keyword.)
Top comments (0)