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.

60-90 minutes intermediate 10 steps
Last updated: Jul 18, 2026

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

1

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.

Tip: If you already run HAProxy Enterprise, skip the SPOA steps entirely and enable the built-in WAF via its own configuration. This guide targets open source HAProxy, which has no native WAF.
2

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
Tip: Stick-tables and ACLs are fast and run in-process, but they are not a substitute for a WAF. They cannot understand SQL injection or XSS payloads; that is what the Coraza engine adds in the next steps.
3

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
Warning: coraza-spoa is a preview-status project and its configuration format has changed between releases. Always cross-check the exact config keys against the version you built, since fields shown here may differ in newer or older releases.
4

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
Tip: Keep SecRuleEngine on DetectionOnly at first. It logs would-be blocks without denying traffic, so you can identify false positives before enforcing.
5

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
Warning: If the agent fails to start, it is almost always a rule path or syntax problem. Read the startup output; it names the file and line of the first rule it could not parse.
6

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
Tip: The var-prefix option (coraza) determines the variable names the agent sets, for example txn.coraza.action. Keep it consistent between this file and the ACLs in the next step.
7

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
Warning: While Coraza runs in DetectionOnly mode, the txn.coraza.action variable will not be set to deny, so these rules will not block anything yet. That is intended; you switch to blocking after tuning.
8

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
Tip: Start the agent before reloading HAProxy. With option set-on-error error configured, HAProxy fails open if the agent is unreachable; check your logs so an agent outage does not silently disable protection.
9

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.

Warning: If attacks are not blocked, verify SecRuleEngine is On, the agent restarted cleanly, and the var-prefix in coraza.cfg matches the variable name in your http-request deny ACL. If only request-body attacks slip through, confirm option http-buffer-request is set on the frontend.
10

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.

Tip: Add the include for your custom exclusion file after the CRS rules in config.yaml so your overrides take precedence. Start at paranoia level 1 and raise it gradually as you tune.

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.log for 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