How to Set Up a WAF with HAProxy
Complete guide to adding web application firewall protection to HAProxy using coraza-spoa with the OWASP Core Rule Set, plus native ACL and stick-table defenses.
HAProxy is the world's most widely used open source load balancer, but on its own it is not a web application firewall. Open source HAProxy has no native WAF module; it inspects and routes traffic, terminates TLS, and enforces ACLs and rate limits, yet it does not run signature or rule-based application-layer attack detection out of the box.
To add true WAF capability to open source HAProxy, you offload request inspection to an external engine over the Stream Processing Offload Protocol (SPOP), using a Stream Processing Offload Agent (SPOA). The most practical open source option is coraza-spoa, which runs the OWASP Coraza engine and the OWASP Core Rule Set (CRS) as a SPOA that HAProxy consults for every request. HAProxy Enterprise offers a different route: a built-in, ML-powered WAF that runs in-process, with no separate agent to manage.
This guide walks you through both layers of defense. First you harden HAProxy with native ACLs and stick-tables for rate limiting and basic filtering, then you deploy coraza-spoa with the OWASP CRS for full application-layer protection. By the end you will have a working, tunable WAF in front of your backends at zero licensing cost.
Prerequisites
- HAProxy 2.6+ recommended (SPOE has been available since 1.7, but run a current LTS branch)
- Root or sudo access to your server
- Ubuntu 22.04+, Debian 12+, or RHEL/Rocky 9+ (other distros may vary)
- A working backend application already proxied through HAProxy
- Go 1.25+ and Git installed (coraza-spoa's go.mod requires a current Go toolchain to build from source)
- Basic familiarity with HAProxy configuration (frontends, backends, ACLs)
Step-by-Step Guide
Choose Your HAProxy WAF Approach
Open source HAProxy does not ship a WAF module. There are three real options for adding one, and it is worth picking deliberately before you install anything:
- coraza-spoa (open source, recommended here): Runs the OWASP Coraza engine and the OWASP Core Rule Set as a Stream Processing Offload Agent. HAProxy sends each request to the agent over SPOP and acts on the verdict. Same SecLang rules as ModSecurity.
- ModSecurity SPOA (open source): An older SPOA that embeds the ModSecurity engine. It works but is less actively maintained than Coraza and is effectively superseded by coraza-spoa for new deployments.
- HAProxy Enterprise WAF (commercial): A built-in, ML-powered WAF that runs in-process with the load balancer, with an optional OWASP CRS compatibility mode. No separate agent, near-zero added latency, but it requires an Enterprise license.
Regardless of which WAF you choose, HAProxy's native ACLs and stick-tables handle rate limiting and coarse filtering. This guide uses coraza-spoa because it is free, open source, and runs the same CRS rules as ModSecurity.
Add Baseline Protection with ACLs and Stick-Tables
Before adding a WAF engine, use HAProxy's native features for rate limiting and basic request filtering. Stick-tables track per-client state; ACLs let you block or throttle on that state. Add this to your frontend in /etc/haproxy/haproxy.cfg:
frontend web
mode http
bind :80
# Buffer the request body so the WAF can inspect POST and JSON payloads later
option http-buffer-request
# Track request rate per source IP over a 10s window
stick-table type ip size 100k expire 30s store http_req_rate(10s)
http-request track-sc0 src
# Deny clients making more than 100 requests per 10 seconds
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 100 }
# Basic path-based filtering example
acl block_path path_beg /.git /.env /wp-login.php
http-request deny deny_status 403 if block_path
default_backend web-backend
Reload and confirm the configuration is valid:
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy
Install coraza-spoa
coraza-spoa is a Go program that runs the Coraza WAF engine and exposes it to HAProxy over SPOP. Build it from source:
# Clone the SPOA
cd /opt
sudo git clone https://github.com/corazawaf/coraza-spoa.git
cd coraza-spoa
# Build the binary with Mage (there is no Makefile; requires Go 1.25+ per go.mod)
sudo go run mage.go build
# Install the compiled binary onto your PATH (it is written to build/)
sudo cp build/coraza-spoa /usr/local/bin/
Create a directory to hold the agent configuration and rules:
sudo mkdir -p /etc/coraza-spoa/rules
Download and Configure the OWASP Core Rule Set
Coraza runs standard OWASP CRS rules, the same SecLang rules ModSecurity uses. Fetch the CRS and its base configuration:
cd /etc/coraza-spoa/rules
sudo git clone https://github.com/coreruleset/coreruleset.git
cd coreruleset
# Create the CRS setup file from the example
sudo cp crs-setup.conf.example crs-setup.conf
You also need a Coraza base configuration, which sets the rule engine mode and request body handling. Create /etc/coraza-spoa/rules/coraza.conf with the core directives:
# Start in detection mode until you have tuned false positives
SecRuleEngine DetectionOnly
SecRequestBodyAccess On
SecRequestBodyLimit 13107200
SecRequestBodyLimitAction Reject
SecResponseBodyAccess On
SecResponseBodyMimeType text/plain text/html text/xml application/json
SecAuditEngine RelevantOnly
SecAuditLogParts ABIJDEFHZ
SecAuditLog /var/log/coraza-spoa/audit.log
Configure the coraza-spoa Agent
The agent needs its own config that tells it which rules to load and where to listen for HAProxy. Create /etc/coraza-spoa/config.yaml:
# coraza-spoa agent configuration
bind: 127.0.0.1:9000
log_level: info
# Must match the name of an application defined in the list below
default_application: default
# applications is a YAML list; each entry is an object with a name
applications:
- name: default
directives: |
Include /etc/coraza-spoa/rules/coraza.conf
Include /etc/coraza-spoa/rules/coreruleset/crs-setup.conf
Include /etc/coraza-spoa/rules/coreruleset/rules/*.conf
# Enable response-phase inspection so the coraza-res message and the
# http-response deny rule actually run (defaults to false)
response_check: true
transaction_ttl_ms: 60000
log_level: info
log_file: /var/log/coraza-spoa/coraza.log
Create the log directory:
sudo mkdir -p /var/log/coraza-spoa
Test that the agent starts and loads the rules without error:
sudo coraza-spoa -config /etc/coraza-spoa/config.yaml
Configure the HAProxy SPOE Filter
HAProxy talks to the agent through a Stream Processing Offload Engine (SPOE) configuration file. This defines which request data is sent to Coraza and which variables the verdict is written back to. Create /etc/haproxy/coraza.cfg:
[coraza]
spoe-agent coraza-agent
messages coraza-res
groups coraza-req
option var-prefix coraza
option set-on-error error
timeout hello 2s
timeout idle 2m
timeout processing 500ms
use-backend coraza-spoa
log global
spoe-message coraza-req
args app=var(txn.coraza.app) src-ip=src src-port=src_port dst-ip=dst dst-port=dst_port method=method path=path query=query version=req.ver headers=req.hdrs body=req.body exportRuleIDs=bool(false)
spoe-message coraza-res
args app=var(txn.coraza.app) id=var(txn.coraza.id) version=res.ver status=status headers=res.hdrs body=res.body exportRuleIDs=bool(false) detect-only=bool(false)
event on-http-response
spoe-group coraza-req
messages coraza-req
The request is sent via a spoe-group that the frontend triggers with send-spoe-group; the response message fires automatically on the on-http-response event. The argument order matches the official coraza.cfg example exactly, and it is required to be in this order.
Then define a backend pointing at the agent so HAProxy knows where to reach it. Add this to /etc/haproxy/haproxy.cfg:
backend coraza-spoa
mode tcp
option spop-check
server coraza 127.0.0.1:9000 check
Wire the WAF Decision into Your Frontend
Now attach the SPOE filter to your frontend and act on the verdict Coraza returns. Update the frontend from Step 2 in /etc/haproxy/haproxy.cfg:
frontend web
mode http
bind :80
# Buffer the request body so Coraza can inspect POST and JSON payloads
option http-buffer-request
# Native rate limiting (from Step 2)
stick-table type ip size 100k expire 30s store http_req_rate(10s)
http-request track-sc0 src
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 100 }
# Select which coraza-spoa application this frontend uses
http-request set-var(txn.coraza.app) str(default)
# Attach the Coraza WAF filter and send the request to the agent
filter spoe engine coraza config /etc/haproxy/coraza.cfg
http-request send-spoe-group coraza coraza-req
# Deny requests and responses Coraza flags as attacks
http-request deny deny_status 403 if { var(txn.coraza.action) -m str deny }
http-response deny deny_status 403 if { var(txn.coraza.action) -m str deny }
default_backend web-backend
The option http-buffer-request line is not optional if you care about POST-body attacks. Without it, HAProxy does not reliably populate the req.body sample before the request is sent to the agent, so SQLi or XSS payloads carried in form or JSON request bodies are never sent to Coraza and go uninspected, even though SecRequestBodyAccess is On. Request-body inspection is bounded by tune.bufsize (default 16 KB): HAProxy can only buffer up to about one bufsize, so bodies larger than that are not fully sent to Coraza. If you need to inspect larger form or JSON bodies, raise tune.bufsize modestly (for example to 64 KB) rather than matching a multi-megabyte SecRequestBodyLimit, which would multiply per-connection memory and risk an out-of-memory condition.
Validate the full configuration before reloading:
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
Run coraza-spoa as a Service
Run the agent under systemd so it starts on boot and restarts on failure. Create /etc/systemd/system/coraza-spoa.service:
[Unit]
Description=Coraza SPOA WAF agent for HAProxy
After=network.target
[Service]
ExecStart=/usr/local/bin/coraza-spoa -config /etc/coraza-spoa/config.yaml
Restart=on-failure
User=haproxy
Group=haproxy
[Install]
WantedBy=multi-user.target
Enable and start the agent, then reload HAProxy:
sudo systemctl daemon-reload
sudo systemctl enable --now coraza-spoa
sudo systemctl reload haproxy
# Confirm the agent is running and listening
sudo systemctl status coraza-spoa
Test the WAF
Temporarily set SecRuleEngine On in /etc/coraza-spoa/rules/coraza.conf, restart the agent, and send known attack payloads. They should be blocked with a 403:
sudo systemctl restart coraza-spoa
# SQL injection attempt (should be blocked)
curl -I 'http://your-server.com/?id=1%20OR%201=1'
# XSS attempt (should be blocked)
curl -I 'http://your-server.com/?q=<script>alert(1)</script>'
# Request-body SQL injection attempt (should be blocked with http-buffer-request enabled)
curl -I -X POST -d 'id=1 OR 1=1' 'http://your-server.com/login'
# A normal request (should pass with 200)
curl -I 'http://your-server.com/'
Confirm the blocks appear in the Coraza audit log:
sudo tail -f /var/log/coraza-spoa/audit.log
You should see entries with the matched CRS rule IDs and the anomaly score that triggered the deny.
Tune for False Positives
Run in DetectionOnly mode for one to two weeks and review the audit log for legitimate requests that would have been blocked. Tune using standard CRS mechanisms in a custom file loaded after the rules, for example /etc/coraza-spoa/rules/custom-exclusions.conf:
# Disable a specific rule that misfires
SecRuleRemoveById 942100
# Disable a rule only for a specific path
SecRule REQUEST_URI "@beginsWith /api/upload" \
"id:1000,phase:1,pass,nolog,ctl:ruleRemoveById=920420"
You can also lower the CRS paranoia level in crs-setup.conf to reduce false positives (1 = fewest false positives, 4 = strictest):
SecAction "id:900000,phase:1,pass,t:none,nolog,\
setvar:tx.blocking_paranoia_level=1"
Once the log is clean, set SecRuleEngine On and restart the agent to begin enforcing.
Conclusion & Next Steps
Your HAProxy instance now has layered protection: native ACLs and stick-tables for rate limiting and coarse filtering, plus a full application-layer WAF via coraza-spoa running the OWASP Core Rule Set. This gives open source HAProxy the WAF capability it lacks natively, at zero licensing cost.
Next steps:
- Monitor
/var/log/coraza-spoa/audit.logfor blocked attacks and false positives - After one to two weeks in DetectionOnly mode, switch SecRuleEngine to On
- Set up log rotation for the audit and agent logs
- Ship Coraza audit logs to your SIEM (ELK, Splunk, Grafana) for correlation
- Schedule regular OWASP CRS updates and re-test after each upgrade
- If in-process performance and ML-based detection matter more than rule transparency, evaluate the built-in WAF in HAProxy Enterprise as an alternative to the SPOA approach
Troubleshooting
HAProxy reloads but the WAF never blocks anything
Check that the agent is running (systemctl status coraza-spoa) and that SecRuleEngine is set to On rather than DetectionOnly. In DetectionOnly mode Coraza logs matches but never sets the deny verdict, so HAProxy has nothing to act on.
Query-string attacks are blocked but POST-body attacks are not
This means request bodies are not reaching Coraza. Add option http-buffer-request to the frontend so HAProxy buffers and populates req.body before the request is sent to the agent. Without it, SecRequestBodyAccess On has no data to inspect. Note that buffered body size is capped by tune.bufsize (default 16 KB); raise it modestly if you need to inspect larger bodies.
Variable txn.coraza.action is never set
The var-prefix in /etc/haproxy/coraza.cfg must match the variable name in your http-request deny ACL. If var-prefix is coraza, the variable is txn.coraza.action. A mismatch means the ACL silently never matches.
All traffic is being denied after enabling blocking
Usually a false positive storm from an untuned CRS at a high paranoia level. Revert SecRuleEngine to DetectionOnly, lower the paranoia level to 1, and review the audit log for the offending rule IDs before re-enabling.
HAProxy fails open when the agent is down
With option set-on-error error, HAProxy allows traffic through if it cannot reach the agent. This prevents an agent crash from taking your site offline, but it also means an unmonitored agent outage disables protection. Alert on coraza-spoa service health and on SPOE error counters in HAProxy stats.
Agent fails to start with a rule parse error
Read the startup output; it names the file and line of the first unparsable rule. Common causes are a missing Include path, an incomplete CRS checkout, or a coraza-spoa version whose config keys differ from the ones in this guide.
High latency after enabling the WAF
Each request now makes a round trip to the agent. Keep coraza-spoa on the same host as HAProxy (127.0.0.1), tighten the processing timeout, and consider lowering SecRequestBodyLimit or reducing response body inspection if you do not need it.
Frequently Asked Questions
Does open source HAProxy include a built-in WAF?
No. Open source HAProxy is a load balancer and reverse proxy; it has no native WAF module. It provides ACL-based filtering and stick-table rate limiting, but not application-layer attack detection. To get a WAF you either offload inspection to an external engine over SPOP (for example coraza-spoa) or use the commercial HAProxy Enterprise, which has a built-in WAF.
What is SPOA and why does HAProxy need it for a WAF?
SPOA stands for Stream Processing Offload Agent. It is an external program HAProxy talks to over the Stream Processing Offload Protocol (SPOP) to offload work it cannot do itself. Because open source HAProxy has no WAF engine, a SPOA like coraza-spoa runs the WAF in a separate process; HAProxy sends each request to the agent for inspection and enforces the verdict it returns.
Can I run the OWASP Core Rule Set with HAProxy?
Yes. coraza-spoa runs the OWASP Coraza engine, which is fully compatible with the ModSecurity SecLang rule language and the OWASP CRS. Your CRS rules and any custom SecLang rules work the same way they would under ModSecurity. HAProxy Enterprise also offers an optional CRS compatibility mode that runs CRS rules through its ML-powered engine.
Should I use coraza-spoa or the ModSecurity SPOA?
For new deployments, coraza-spoa is the better choice. It runs the actively developed OWASP Coraza engine in pure Go, uses the same CRS rules, and is easier to build and containerize. The older ModSecurity SPOA embeds the ModSecurity engine and is effectively superseded. Note that coraza-spoa is still preview status, so validate config formats against the version you install.
How do I do rate limiting in HAProxy without a WAF?
Use stick-tables and ACLs, which are native HAProxy features. Define a stick-table that tracks a metric such as http_req_rate per source IP, track connections with http-request track-sc0 src, then deny requests that exceed a threshold. This runs in-process with no external agent and is your first layer of defense, complementing the deeper inspection a WAF provides.
Does the WAF add latency to HAProxy?
The SPOA approach adds a round trip from HAProxy to the agent for every request, so there is measurable overhead compared to native processing. Keep coraza-spoa on the same host (127.0.0.1) and tune the processing timeout to minimize it. HAProxy Enterprise's built-in WAF avoids this by running in-process, which is its main performance advantage over the open source SPOA route.
What happens if the coraza-spoa agent crashes?
It depends on your SPOE error handling. With option set-on-error error, HAProxy fails open and lets traffic through when the agent is unreachable, which keeps your site up but disables WAF protection during the outage. Because of this, monitor the coraza-spoa service and HAProxy SPOE error counters, and alert on agent health so a silent failure does not leave you unprotected.
Related Guides
WAF Security Best Practices Guide
Essential best practices for configuring and maintaining your Web Application Firewall for optimal security.
How to Install and Configure ModSecurity with NGINX
Complete guide to deploying ModSecurity 3.x with NGINX for free, open-source WAF protection using the OWASP Core Rule Set.