Deploy ModSecurity 3 as a Stream Processing Offload Agent in front of HAProxy to inspect HTTP traffic with the OWASP Core Rule Set. This tutorial covers SPOA compilation, SPOE filter configuration, detection versus blocking modes, and production tuning.
Prerequisites
- A running HAProxy instance with root or sudo access
- Basic familiarity with HAProxy configuration syntax
- A test or staging environment for tuning WAF rules before production rollout
- At least 2 CPU cores and 2GB RAM for compiling libmodsecurity
What this solves
HAProxy has no native request body inspection engine, so it cannot stop SQL injection, XSS or other layer 7 attacks on its own. This tutorial wires HAProxy to ModSecurity 3 via the Stream Processing Offload Engine (SPOE) so every request is inspected by the OWASP Core Rule Set (CRS) before it reaches your backends.
You will build the SPOA (Stream Processing Offload Agent) from source, connect it to HAProxy with SPOE filters, load the CRS, and tune detection versus blocking behavior for production traffic.
Step-by-step installation
Install build dependencies
The SPOA and libmodsecurity must be compiled from source on all four target distributions. Install the toolchain and libraries first.
sudo apt update
sudo apt install -y build-essential git autoconf automake libtool \
pkgconf libpcre2-dev libpcre3-dev libxml2-dev libcurl4-openssl-dev \
libyajl-dev doxygen libgeoip-dev liblua5.3-dev libssl-dev \
zlib1g-dev libmaxminddb-dev cmakesudo dnf groupinstall -y "Development Tools"
sudo dnf install -y epel-release
sudo dnf install -y git autoconf automake libtool pkgconf pcre2-devel \
libxml2-devel libcurl-devel yajl-devel doxygen GeoIP-devel \
lua-devel openssl-devel zlib-devel libmaxminddb-devel cmakeBuild and install libmodsecurity 3
libmodsecurity is the standalone engine that evaluates CRS rules. It is separate from the old Apache/NGINX ModSecurity modules.
cd /usr/local/src
sudo git clone --depth 1 -b v3/master https://github.com/owasp-modsecurity/ModSecurity modsecurity
cd modsecurity
sudo git submodule init
sudo git submodule update
sudo ./build.sh
sudo ./configure
sudo make -j"$(nproc)"
sudo make installThis build takes 15-30 minutes depending on CPU. Verify the shared library was installed correctly.
ls -la /usr/local/modsecurity/lib/libmodsecurity.so*Build the ModSecurity SPOA
The SPOA is the daemon that speaks the SPOE binary protocol to HAProxy and forwards requests to libmodsecurity for inspection.
cd /usr/local/src
sudo git clone https://github.com/haproxytech/spoa-modsecurity
cd spoa-modsecurity
sudo make MODSECURITY_INC=/usr/local/modsecurity/include \
MODSECURITY_LIB=/usr/local/modsecurity/lib
sudo cp spoa /usr/local/bin/modsec-spoa
sudo mkdir -p /etc/modsecurity-spoaInstall HAProxy 2.8 or newer
SPOE support is mature from HAProxy 2.0 onward, but use 2.8 LTS or later for stability and performance fixes relevant to WAF workloads.
sudo apt install -y haproxy
haproxy -vsudo dnf install -y haproxy
haproxy -vIf your distribution ships an older HAProxy, refer to Install and configure HAProxy for high availability load balancing for repository options that provide newer releases.
Download the OWASP Core Rule Set
The CRS provides the generic ruleset for SQLi, XSS, RCE, protocol violations and scanner detection. Pull the latest stable release.
cd /etc/modsecurity-spoa
sudo git clone -b v4.3/master https://github.com/coreruleset/coreruleset crs
sudo cp crs/crs-setup.conf.example crs/crs-setup.confCreate the ModSecurity engine configuration
This file loads libmodsecurity's core directives, the CRS setup file and all CRS rule files in order.
SecRuleEngine DetectionOnly
SecRequestBodyAccess On
SecResponseBodyAccess Off
SecRequestBodyLimit 13107200
SecRequestBodyNoFilesLimit 131072
SecPcreMatchLimit 250000
SecPcreMatchLimitRecursion 250000
SecAuditEngine RelevantOnly
SecAuditLogRelevantStatus "^(?:5|4(?!04))"
SecAuditLogParts ABIJDEFHZ
SecAuditLogType Serial
SecAuditLog /var/log/modsecurity/audit.log
SecDebugLog /var/log/modsecurity/debug.log
SecDebugLogLevel 3
Include /etc/modsecurity-spoa/crs/crs-setup.conf
Include /etc/modsecurity-spoa/crs/rules/*.confSecRuleEngine is set to DetectionOnly deliberately. You will switch this to On only after reviewing logs from real traffic.
sudo mkdir -p /var/log/modsecurity
sudo chown -R haproxy:haproxy /var/log/modsecurity
sudo chmod 750 /var/log/modsecurityCreate the SPOA systemd service
Run the SPOA as a dedicated systemd unit so it starts on boot and restarts automatically on failure.
[Unit]
Description=ModSecurity SPOA for HAProxy
After=network.target
[Service]
Type=simple
User=haproxy
Group=haproxy
ExecStart=/usr/local/bin/modsec-spoa -n 4 -p 12345 -r /etc/modsecurity-spoa/modsecurity.conf
Restart=on-failure
RestartSec=3
LimitNOFILE=65536
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now modsec-spoa
sudo systemctl status modsec-spoaConfigure the SPOE filter definition
The SPOE config tells HAProxy which messages to send to the agent and which variables to expect back.
[modsecurity]
spoe-agent modsecurity-agent
messages check-request
option var-prefix modsec
timeout hello 2s
timeout idle 2m
timeout processing 1s
use-backend spoe-modsecurity
log global
spoe-message check-request
args unique-id method path query req.ver req.hdrs_bin req.body_size req.body
event on-frontend-http-requestConfigure HAProxy to use the SPOE filter
Add the SPOE backend, the filter directive on your frontend, and the ACL that blocks requests flagged by ModSecurity.
global
log /dev/log local0
maxconn 20000
tune.ssl.default-dh-param 2048
defaults
log global
mode http
option httplog
timeout connect 5s
timeout client 30s
timeout server 30s
frontend fe_main
bind *:443 ssl crt /etc/haproxy/certs/example.com.pem
filter spoe engine modsecurity config /etc/haproxy/modsecurity.spoe.conf
http-request deny deny_status 403 if { var(txn.modsec.blocked) -m int gt 0 }
default_backend be_app
backend spoe-modsecurity
mode tcp
balance roundrobin
timeout connect 5s
timeout server 2m
server spoa1 127.0.0.1:12345
backend be_app
balance roundrobin
option httpchk GET /healthz
server app1 203.0.113.10:8080 check
server app2 203.0.113.11:8080 checkIf you have not yet configured TLS on this HAProxy instance, follow Set up HAProxy SSL termination with Let's Encrypt certificates before exposing this frontend publicly.
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxyTune CRS paranoia level and anomaly thresholds
Paranoia level controls how aggressively CRS matches suspicious patterns. Level 1 is the default; higher levels catch more but increase false positives.
SecAction \
"id:900000,\
phase:1,\
nolog,\
pass,\
t:none,\
setvar:tx.paranoia_level=1,\
setvar:tx.inbound_anomaly_score_threshold=5,\
setvar:tx.outbound_anomaly_score_threshold=4"Start at paranoia level 1 with the default anomaly threshold of 5. Only raise the level after a tuning period shows an acceptable false-positive rate.
Add custom rule exceptions
Legitimate application behavior sometimes trips generic rules, such as a CMS admin panel that submits raw HTML in a form field. Exclude specific rule IDs for specific paths instead of disabling entire rule categories.
SecRule REQUEST_URI "@beginsWith /admin/content-editor" \
"id:1000001,\
phase:2,\
pass,\
nolog,\
ctl:ruleRemoveById=941100-941999"Place exclusion files after the CRS rule includes in modsecurity.conf so they can override earlier matches. Document every exclusion with a comment explaining why it exists and who approved it.
Verify your setup
Confirm the SPOA is listening and HAProxy is forwarding traffic to it.
sudo ss -tlnp | grep 12345
sudo journalctl -u modsec-spoa -n 50 --no-pagerTest detection with a known malicious payload. In DetectionOnly mode this should log the event but still return a 200 response.
curl -s -o /dev/null -w "%{http_code}\n" "https://example.com/?id=1' OR '1'='1"
curl -s -o /dev/null -w "%{http_code}\n" "https://example.com/?exec=/bin/bash"Check that the audit log recorded the anomaly.
sudo tail -n 20 /var/log/modsecurity/audit.logAfter reviewing logs for false positives over a representative traffic period, switch to blocking mode and confirm attacks now return 403.
sudo sed -i 's/SecRuleEngine DetectionOnly/SecRuleEngine On/' /etc/modsecurity-spoa/modsecurity.conf
sudo systemctl restart modsec-spoa
curl -s -o /dev/null -w "%{http_code}\n" "https://example.com/?id=1' OR '1'='1"Logging, alerting and testing against attack payloads
Feed the ModSecurity audit log into your centralized logging pipeline rather than tailing it manually in production. If you already run the ELK stack, follow Set up ELK Stack for centralized ModSecurity log analysis and monitoring to parse the JSON audit log format and build dashboards for blocked requests by rule ID and source IP.
Run a broader payload sweep to validate CRS coverage before going live. Test SQL injection, XSS, path traversal and command injection patterns against a staging frontend running in blocking mode.
curl -s -o /dev/null -w "%{http_code}\n" "https://staging.example.com/search?q=<script>alert(1)</script>"
curl -s -o /dev/null -w "%{http_code}\n" "https://staging.example.com/file?path=../../etc/passwd"
curl -s -o /dev/null -w "%{http_code}\n" "https://staging.example.com/ping?host=127.0.0.1;cat /etc/shadow"All three should return 403. If any return 200, check the anomaly score threshold and confirm the relevant CRS rule files loaded without syntax errors.
Performance and high availability considerations
Each SPOA process is single-threaded per worker, so scale the -n worker count to match available CPU cores on the SPOA host. Keep the SPOA on the same host as HAProxy or on a low-latency internal network, since every request now incurs an extra round trip.
| Tuning area | Recommendation |
|---|---|
| SPOA workers | Set -n to CPU core count minus 1, reserving a core for HAProxy |
| SPOE timeout processing | Keep under 1s; requests exceeding this fail open or closed based on on-error setting |
| Request body limit | Cap SecRequestBodyLimit to your largest legitimate upload plus margin, not unlimited |
| Audit log volume | Use RelevantOnly and ship logs asynchronously to avoid disk I/O blocking inspection |
For high availability, run HAProxy and the SPOA in an active-passive or active-active pair behind keepalived, and run a local SPOA instance per HAProxy node rather than a shared remote SPOA pool. This avoids a single SPOA becoming a bottleneck or single point of failure for every frontend. See Set up HAProxy high availability with keepalived clustering for automatic failover for the VRRP configuration.
Decide the SPOE failure mode explicitly. By default, if the SPOA is unreachable, HAProxy fails open and traffic passes uninspected. For security-critical services, consider failing closed by adding an ACL that denies traffic when the SPOE variable is unset, but test this carefully since it turns a WAF outage into a full service outage.
Common issues
| Symptom | Cause | Fix |
|---|---|---|
| All requests return 403 immediately | Anomaly threshold too low or a broad rule exclusion is missing | Set SecRuleEngine DetectionOnly, review /var/log/modsecurity/audit.log, raise threshold temporarily |
| HAProxy config check fails on filter spoe | SPOE config file path is wrong or has a syntax error | Run sudo haproxy -c -f /etc/haproxy/haproxy.cfg and check the reported line number |
| modsec-spoa fails to start | libmodsecurity.so not found by the dynamic linker | Run sudo ldconfig /usr/local/modsecurity/lib and add that path to /etc/ld.so.conf.d/ |
| Legitimate file uploads get blocked | SecRequestBodyLimit too low or CRS rule 200002 triggering on multipart data | Increase the body limit and add a scoped exclusion for the upload endpoint |
| High latency on every request | SPOA and HAProxy on different hosts with network round trip overhead | Colocate SPOA on the same host as HAProxy or on a sub-millisecond LAN link |
| Audit log grows unbounded | SecAuditEngine set to On instead of RelevantOnly | Set SecAuditEngine RelevantOnly and configure logrotate on the audit log path |
Next steps
- Configure ModSecurity 3 web application firewall with OWASP Core Rule Set for advanced threat protection
- Configure ModSecurity machine learning anomaly detection for automated threat protection
- Integrate ModSecurity 3 with SOAR platforms for automated incident response and threat detection
- Configure HAProxy advanced routing with ACLs and maps for intelligent traffic management
- Configure HAProxy WAF logging with Fluentd and Loki
Running this in production?
Automated install script
Run this to automate the entire setup
#!/usr/bin/env bash
set -euo pipefail
# ------------------------------------------------------------------------
# HAProxy + ModSecurity3 (SPOE/SPOA) WAF integration installer
# Supports: Ubuntu, Debian, AlmaLinux, Rocky Linux
# ------------------------------------------------------------------------
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
log() { echo -e "${GREEN}[+] $*${NC}"; }
warn() { echo -e "${YELLOW}[!] $*${NC}"; }
err() { echo -e "${RED}[x] $*${NC}" >&2; }
TOTAL_STEPS=12
STEP=0
step() { STEP=$((STEP+1)); echo -e "\n${GREEN}[$STEP/$TOTAL_STEPS] $*${NC}"; }
SRC_DIR="/usr/local/src"
SPOA_CFG_DIR="/etc/modsecurity-spoa"
CRS_TAG="v4.3/master"
MOD_TAG="v3/master"
usage() {
echo "Usage: $0 [--backend-ip <IP>] [--backend-port <PORT>] [--domain <DOMAIN>]"
echo " --backend-ip IP of the backend application server (default: 127.0.0.1)"
echo " --backend-port Port of the backend application (default: 8080)"
echo " --domain Domain name for HAProxy frontend (default: _ wildcard)"
exit 1
}
BACKEND_IP="127.0.0.1"
BACKEND_PORT="8080"
DOMAIN="_"
while [ $# -gt 0 ]; do
case "$1" in
--backend-ip) BACKEND_IP="$2"; shift 2 ;;
--backend-port) BACKEND_PORT="$2"; shift 2 ;;
--domain) DOMAIN="$2"; shift 2 ;;
-h|--help) usage ;;
*) err "Unknown argument: $1"; usage ;;
esac
done
if [ "$(id -u)" -ne 0 ]; then
err "This script must be run as root (use sudo)."
exit 1
fi
# --- Distro detection ---
if [ -f /etc/os-release ]; then
. /etc/os-release
case "$ID" in
ubuntu|debian) PKG_MGR="apt"; PKG_INSTALL="apt install -y"; SVC_NAME="haproxy" ;;
almalinux|rocky|centos|rhel|ol|fedora) PKG_MGR="dnf"; PKG_INSTALL="dnf install -y"; SVC_NAME="haproxy" ;;
amzn) PKG_MGR="yum"; PKG_INSTALL="yum install -y"; SVC_NAME="haproxy" ;;
*) err "Unsupported distro: $ID"; exit 1 ;;
esac
else
err "/etc/os-release not found. Cannot detect distro."
exit 1
fi
log "Detected distro: $ID ($PKG_MGR)"
# --- Rollback on failure ---
cleanup_on_error() {
err "Installation failed at step $STEP. Rolling back partial changes..."
systemctl stop modsec-spoa.service 2>/dev/null || true
rm -f /etc/systemd/system/modsec-spoa.service
systemctl daemon-reload 2>/dev/null || true
warn "Source directories left in $SRC_DIR for inspection. Remove manually if desired."
}
trap cleanup_on_error ERR
# --- Step 1: install build dependencies ---
step "Installing build dependencies..."
if [ "$PKG_MGR" = "apt" ]; then
apt update
$PKG_INSTALL build-essential git autoconf automake libtool \
pkgconf libpcre2-dev libpcre3-dev libxml2-dev libcurl4-openssl-dev \
libyajl-dev doxygen libgeoip-dev liblua5.3-dev libssl-dev \
zlib1g-dev libmaxminddb-dev cmake
else
dnf groupinstall -y "Development Tools"
dnf install -y epel-release || true
$PKG_INSTALL git autoconf automake libtool pkgconf pcre2-devel \
libxml2-devel libcurl-devel yajl-devel doxygen GeoIP-devel \
lua-devel openssl-devel zlib-devel libmaxminddb-devel cmake
fi
# --- Step 2: build & install libmodsecurity ---
step "Cloning and building libmodsecurity 3 (this takes 15-30 minutes)..."
mkdir -p "$SRC_DIR"
cd "$SRC_DIR"
if [ ! -d modsecurity ]; then
git clone --depth 1 -b "$MOD_TAG" https://github.com/owasp-modsecurity/ModSecurity modsecurity
fi
cd modsecurity
git submodule init
git submodule update
if [ ! -f /usr/local/modsecurity/lib/libmodsecurity.so ]; then
./build.sh
./configure
make -j"$(nproc)"
make install
else
warn "libmodsecurity already installed, skipping build."
fi
step "Verifying libmodsecurity shared library..."
if ! ls /usr/local/modsecurity/lib/libmodsecurity.so* >/dev/null 2>&1; then
err "libmodsecurity build failed - shared library not found."
exit 1
fi
log "libmodsecurity installed successfully."
# --- Step 3: build SPOA ---
step "Building the ModSecurity SPOA daemon..."
cd "$SRC_DIR"
if [ ! -d spoa-modsecurity ]; then
git clone https://github.com/haproxytech/spoa-modsecurity
fi
cd spoa-modsecurity
make MODSECURITY_INC=/usr/local/modsecurity/include \
MODSECURITY_LIB=/usr/local/modsecurity/lib
cp spoa /usr/local/bin/modsec-spoa
chmod 755 /usr/local/bin/modsec-spoa
mkdir -p "$SPOA_CFG_DIR"
chown root:root "$SPOA_CFG_DIR"
chmod 755 "$SPOA_CFG_DIR"
# --- Step 4: install HAProxy ---
step "Installing HAProxy..."
$PKG_INSTALL haproxy
haproxy -v || { err "HAProxy install failed."; exit 1; }
# Detect distro-specific HAProxy config path
if [ -d /etc/haproxy ]; then
HAPROXY_CFG_DIR="/etc/haproxy"
else
err "Could not locate /etc/haproxy config directory."
exit 1
fi
# --- Step 5: download OWASP CRS ---
step "Downloading OWASP Core Rule Set..."
cd "$SPOA_CFG_DIR"
if [ ! -d crs ]; then
git clone -b "$CRS_TAG" https://github.com/coreruleset/coreruleset crs
fi
cp -n crs/crs-setup.conf.example crs/crs-setup.conf
chown -R root:root crs
chmod -R 755 crs
# --- Step 6: create log directories ---
step "Creating ModSecurity log directories..."
mkdir -p /var/log/modsecurity
chown root:root /var/log/modsecurity
chmod 750 /var/log/modsecurity
# --- Step 7: write modsecurity engine config (detection-only by default) ---
step "Writing ModSecurity engine configuration (DetectionOnly mode)..."
cat > "$SPOA_CFG_DIR/modsecurity.conf" <<EOF
SecRuleEngine DetectionOnly
SecRequestBodyAccess On
SecResponseBodyAccess Off
SecRequestBodyLimit 13107200
SecRequestBodyNoFilesLimit 131072
SecPcreMatchLimit 250000
SecPcreMatchLimitRecursion 250000
SecAuditEngine RelevantOnly
SecAuditLogRelevantStatus "^(?:5|4(?!04))"
SecAuditLogParts ABIJDEFHZ
SecAuditLogType Serial
SecAuditLog /var/log/modsecurity/audit.log
SecDebugLog /var/log/modsecurity/debug.log
SecDebugLogLevel 3
Include ${SPOA_CFG_DIR}/crs/crs-setup.conf
Include ${SPOA_CFG_DIR}/crs/rules/*.conf
EOF
chown root:root "$SPOA_CFG_DIR/modsecurity.conf"
chmod 644 "$SPOA_CFG_DIR/modsecurity.conf"
warn "SecRuleEngine is set to DetectionOnly. Review logs before enabling blocking mode."
# --- Step 8: create systemd service for SPOA ---
step "Creating systemd service for modsec-spoa..."
cat > /etc/systemd/system/modsec-spoa.service <<EOF
[Unit]
Description=HAProxy ModSecurity SPOA
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/modsec-spoa -f ${SPOA_CFG_DIR}/modsecurity.conf -p 12345
Restart=on-failure
User=root
[Install]
WantedBy=multi-user.target
EOF
chmod 644 /etc/systemd/system/modsec-spoa.service
systemctl daemon-reload
systemctl enable --now modsec-spoa.service
# --- Step 9: configure SPOE file for HAProxy ---
step "Writing SPOE configuration file..."
cat > "${HAPROXY_CFG_DIR}/modsecurity-spoe.conf" <<EOF
[modsecurity]
spoe-agent modsecurity-agent
messages check-request
option var-prefix modsec
timeout hello 2s
timeout idle 2m
timeout processing 2s
use-backend spoe-modsecurity
spoe-message check-request
args unique-id method path query req.ver req.hdrs_bin req.body_size req.body
event on-frontend-http-request
backend spoe-modsecurity
mode tcp
server modsec-spoa1 127.0.0.1:12345
EOF
chown root:root "${HAPROXY_CFG_DIR}/modsecurity-spoe.conf"
chmod 644 "${HAPROXY_CFG_DIR}/modsecurity-spoe.conf"
# --- Step 10: patch haproxy.cfg with frontend/backend and filter ---
step "Configuring HAProxy frontend/backend for WAF filtering..."
HAPROXY_MAIN_CFG="${HAPROXY_CFG_DIR}/haproxy.cfg"
cp "$HAPROXY_MAIN_CFG" "${HAPROXY_MAIN_CFG}.bak.$(date +%s)"
if ! grep -q "filter spoe" "$HAPROXY_MAIN_CFG"; then
cat >> "$HAPROXY_MAIN_CFG" <<EOF
frontend fe_waf
Review the script before running. Execute with: bash install.sh