Every web server connected to the internet gets attacked. Not occasionally — constantly. Automated scanners probe for SQL injection within minutes of a new deployment going live. Credential stuffing bots hammer login endpoints around the clock. Path traversal attempts, cross-site scripting payloads, and remote code execution exploits arrive in a steady stream that never stops.
Most server hardening guides tell you to change your SSH port and install Fail2ban. That covers brute force SSH attacks — roughly 5% of the threat landscape. The other 95% targets your web application layer: HTTP requests carrying malicious payloads that your firewall rules will never see because they arrive on port 443 inside perfectly valid TCP connections.
A Web Application Firewall (WAF) inspects HTTP traffic at the application layer, analyzing request headers, URI parameters, POST bodies, and cookies against a rule set designed to identify attack patterns. A properly configured WAF with the OWASP Core Rule Set blocks SQL injection, cross-site scripting (XSS), remote file inclusion (RFI), local file inclusion (LFI), command injection, directory traversal, HTTP request smuggling, and dozens of other attack categories — automatically, before they reach your application code.
This guide covers everything you need to deploy a production-grade WAF on both Nginx and Apache using ModSecurity v3 with the OWASP Core Rule Set v4. Every configuration block is copy-paste ready. We also cover rate limiting, SYN flood protection, Fail2ban integration, and security headers — the full stack that turns a bare server into a hardened platform.
Before installing anything, it helps to understand the mechanics. A WAF operates at OSI Layer 7 — the application layer. Unlike a network firewall (iptables, UFW) that filters packets based on IP addresses, ports, and protocols, a WAF reads and interprets HTTP requests and responses.
ModSecurity processes each request through five phases:
Phase 1 — Request Headers: The WAF examines the HTTP method, URI, query string, and all request headers. Rules in this phase catch attacks embedded in URLs, malformed headers, and protocol violations.
Phase 2 — Request Body: POST data, file uploads, JSON/XML payloads — everything in the request body gets inspected. This is where SQL injection in form fields, XSS in submitted content, and malicious file uploads are caught.
Phase 3 — Response Headers: The WAF inspects outgoing response headers. Rules here detect information leakage — server version strings, error messages that reveal database structure, stack traces exposed to clients.
Phase 4 — Response Body: The full response body is scanned. This catches data exfiltration attempts, credit card numbers in responses, and application errors that should never reach the client.
Phase 5 — Logging: Audit logging captures the full request-response transaction for blocked or flagged requests. This data feeds into Fail2ban and SIEM systems for threat analysis.
ModSecurity uses an anomaly scoring model with the OWASP CRS. Instead of immediately blocking on a single rule match, each matched rule adds points to the request's anomaly score. When the total score exceeds a configured threshold, the request is blocked. This dramatically reduces false positives — a single suspicious parameter might score 5 points, but only a genuinely malicious request accumulates enough points to cross the blocking threshold of 5 (paranoia level 1) or higher.
ModSecurity v3 (libmodsecurity) is the current production release. It separates the core engine from the web server connector, meaning the same rule engine works with both Nginx and Apache through different connectors. On Nginx, you need two components: libmodsecurity3 and the Nginx ModSecurity connector module.
On Debian/Ubuntu:
# Install dependencies
sudo apt update
sudo apt install -y apt-utils autoconf automake build-essential \
git libcurl4-openssl-dev libgeoip-dev liblmdb-dev libpcre2-dev \
libtool libxml2-dev libyajl-dev pkgconf wget zlib1g-dev
# Clone and build libmodsecurity3
cd /opt
sudo git clone --depth 1 -b v3/master https://github.com/owasp-modsecurity/ModSecurity.git
cd ModSecurity
sudo git submodule init
sudo git submodule update
sudo ./build.sh
sudo ./configure
sudo make -j$(nproc)
sudo make install
Next, compile the Nginx connector module. You need to compile it against your existing Nginx version:
# Get your Nginx version and configure arguments
nginx -v
nginx -V 2>&1 | grep "configure arguments"
# Clone the connector
cd /opt
sudo git clone --depth 1 https://github.com/owasp-modsecurity/ModSecurity-nginx.git
# Download matching Nginx source
NGINX_VERSION=$(nginx -v 2>&1 | grep -oP 'nginx/\K[0-9.]+')
wget http://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz
tar -xzf nginx-${NGINX_VERSION}.tar.gz
cd nginx-${NGINX_VERSION}
# Compile as dynamic module (use your existing configure flags + add module)
./configure --with-compat --add-dynamic-module=/opt/ModSecurity-nginx
make modules
# Install the module
sudo cp objs/ngx_http_modsecurity_module.so /etc/nginx/modules/
Load the module in your Nginx configuration:
# /etc/nginx/nginx.conf — add at the very top, before events block
load_module modules/ngx_http_modsecurity_module.so;
Apache's installation is more straightforward since ModSecurity has native Apache support through mod_security2:
# Debian/Ubuntu
sudo apt update
sudo apt install -y libapache2-mod-security2
# Enable the module
sudo a2enmod security2
# RHEL/CentOS/AlmaLinux
sudo dnf install -y mod_security mod_security_crs
# Verify module is loaded
apachectl -M | grep security
Apache loads ModSecurity automatically after installation. The default configuration file is at /etc/modsecurity/modsecurity.conf on Debian-based systems or /etc/httpd/conf.d/mod_security.conf on RHEL-based systems.
ModSecurity without rules is an engine without fuel. The OWASP Core Rule Set (CRS) is the standard open-source rule set that provides protection against the OWASP Top 10 and hundreds of other attack categories.
# Download OWASP CRS v4
cd /opt
sudo wget https://github.com/coreruleset/coreruleset/archive/v4.7.0.tar.gz
sudo tar -xzf v4.7.0.tar.gz
sudo mv coreruleset-4.7.0 /etc/modsecurity/crs
# Set up configuration
cd /etc/modsecurity/crs
sudo cp crs-setup.conf.example crs-setup.conf
Now configure ModSecurity's main settings. This is the core configuration that controls the engine behavior:
# /etc/modsecurity/modsecurity.conf
# Enable the engine — "DetectionOnly" logs but doesn't block
# Start with DetectionOnly, switch to "On" after tuning
SecRuleEngine On
# Request body handling
SecRequestBodyAccess On
SecRequestBodyLimit 13107200
SecRequestBodyNoFilesLimit 131072
SecRequestBodyLimitAction Reject
# Response body handling
SecResponseBodyAccess On
SecResponseBodyMimeType text/plain text/html text/xml application/json
SecResponseBodyLimit 524288
SecResponseBodyLimitAction ProcessPartial
# Temp and data directories
SecTmpDir /tmp/modsecurity/tmp
SecDataDir /tmp/modsecurity/data
# Audit logging — critical for Fail2ban integration
SecAuditEngine RelevantOnly
SecAuditLogRelevantStatus "^(?:5|4(?!04))"
SecAuditLogParts ABIJDEFHZ
SecAuditLogType Serial
SecAuditLog /var/log/modsecurity/audit.log
# Debug log (disable in production, enable for troubleshooting)
SecDebugLog /var/log/modsecurity/debug.log
SecDebugLogLevel 0
# PCRE tuning
SecPcreMatchLimit 500000
SecPcreMatchLimitRecursion 500000
# Unicode mapping
SecUnicodeMapFile unicode.mapping 20127
With ModSecurity and CRS installed, enable it in your Nginx server blocks:
# /etc/nginx/conf.d/modsecurity.conf — global include
modsecurity on;
modsecurity_rules_file /etc/nginx/modsecurity/main.conf;
# /etc/nginx/modsecurity/main.conf — rule loading
Include /etc/modsecurity/modsecurity.conf
Include /etc/modsecurity/crs/crs-setup.conf
Include /etc/modsecurity/crs/rules/*.conf
Enable ModSecurity per server block or globally:
# /etc/nginx/sites-available/yoursite.conf
server {
listen 443 ssl http2;
server_name example.com;
# Enable WAF for this server block
modsecurity on;
modsecurity_rules_file /etc/nginx/modsecurity/main.conf;
# TLS configuration (covered later)
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# /etc/apache2/mods-enabled/security2.conf
<IfModule security2_module>
SecDataDir /tmp/modsecurity/data
IncludeOptional /etc/modsecurity/modsecurity.conf
IncludeOptional /etc/modsecurity/crs/crs-setup.conf
IncludeOptional /etc/modsecurity/crs/rules/*.conf
</IfModule>
You can also enable or disable ModSecurity per virtual host:
# /etc/apache2/sites-available/yoursite.conf
<VirtualHost *:443>
ServerName example.com
# Enable WAF
SecRuleEngine On
# Per-vhost rule exclusions (if needed)
SecRuleRemoveById 920350
DocumentRoot /var/www/html
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
</VirtualHost>
The OWASP CRS uses paranoia levels (PL1 through PL4) to control rule aggressiveness. This is the most important tuning decision you will make:
Paranoia Level 1 (default): Core rules only. Catches obvious attacks — straightforward SQL injection, basic XSS, common path traversal. Very few false positives. Suitable for most production environments out of the box.
Paranoia Level 2: Adds rules for more subtle attack patterns. Catches obfuscated SQL injection, encoded XSS payloads, and more complex attack variants. May trigger on some legitimate traffic — CMS admin panels, rich text editors, API endpoints accepting complex JSON.
Paranoia Level 3: Aggressive detection. Catches highly obfuscated attacks, unusual encodings, and edge-case exploit variants. Expect false positives on complex web applications. Requires active rule tuning.
Paranoia Level 4: Maximum paranoia. Flags anything remotely suspicious. Not recommended for production without extensive rule exclusion work. Used for high-security environments where every request is scrutinized.
Configure the paranoia level in the CRS setup file:
# /etc/modsecurity/crs/crs-setup.conf
# Set paranoia level (1-4)
SecAction "id:900000, phase:1, pass, t:none, \
nolog, setvar:tx.blocking_paranoia_level=1"
# Anomaly score thresholds
# Inbound: block when score reaches this value
SecAction "id:900110, phase:1, pass, t:none, \
nolog, setvar:tx.inbound_anomaly_score_threshold=5"
# Outbound: block when response score reaches this value
SecAction "id:900111, phase:1, pass, t:none, \
nolog, setvar:tx.outbound_anomaly_score_threshold=4"
The anomaly scoring threshold works hand-in-hand with paranoia levels. At PL1 with a threshold of 5, a single critical rule match (which scores 5 points) triggers a block. At PL2 with a threshold of 10, the request needs to trigger multiple rules before being blocked. Start with PL1/threshold 5 and adjust based on your audit logs.
Here is exactly what a properly configured ModSecurity + OWASP CRS deployment protects against:
SQL Injection (CRS Rules 942xxx): Detects SQL syntax in request parameters, headers, and cookies. Catches UNION SELECT, boolean-based blind injection (AND 1=1), time-based injection (SLEEP(5)), error-based injection, and stacked queries. Includes rules for MySQL, PostgreSQL, MSSQL, Oracle, and SQLite syntax.
Cross-Site Scripting / XSS (CRS Rules 941xxx): Blocks <script> tags, event handlers (onerror, onload), JavaScript URIs, SVG-based XSS, and encoded variants. Catches both reflected and stored XSS payloads in parameters, headers, and file uploads.
Remote Code Execution / Command Injection (CRS Rules 932xxx): Detects shell metacharacters (;, |, $(), backticks), common command names (wget, curl, nc, /bin/sh), and OS-specific command patterns. Blocks both Unix and Windows command injection attempts.
Local/Remote File Inclusion (CRS Rules 930xxx, 931xxx): Catches path traversal sequences (../../../etc/passwd), null byte injection, wrapper protocols (php://filter, data://), and remote file inclusion via URL parameters pointing to external hosts.
HTTP Protocol Violations (CRS Rules 920xxx): Rejects malformed requests, invalid HTTP methods, missing required headers, oversized requests, request smuggling attempts, and HTTP response splitting. Enforces protocol compliance at the WAF layer.
Session Fixation (CRS Rules 943xxx): Detects session ID injection in URLs and cookie manipulation attempts.
Java/PHP/Node Deserialization Attacks (CRS Rules 944xxx): Catches serialized object injection patterns targeting common framework vulnerabilities.
No WAF rule set works perfectly out of the box for every application. You will need to write exclusion rules and possibly custom detection rules. Here are practical examples:
# /etc/modsecurity/crs/rules/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf
# WordPress admin — exclude rules that flag legitimate admin actions
SecRule REQUEST_URI "@beginsWith /wp-admin/" \
"id:1001, phase:1, pass, t:none, nolog, \
ctl:ruleRemoveTargetById=941100;ARGS:content, \
ctl:ruleRemoveTargetById=942100;ARGS:content"
# API endpoint accepting JSON with SQL-like syntax in values
SecRule REQUEST_URI "@beginsWith /api/v1/query" \
"id:1002, phase:1, pass, t:none, nolog, \
ctl:ruleRemoveById=942100, \
ctl:ruleRemoveById=942190"
# Whitelist a specific parameter from XSS checks
SecRule ARGS:html_body "@rx .*" \
"id:1003, phase:2, pass, t:none, nolog, \
ctl:ruleRemoveTargetById=941100;ARGS:html_body, \
ctl:ruleRemoveTargetById=941110;ARGS:html_body, \
ctl:ruleRemoveTargetById=941160;ARGS:html_body"
# Custom rule: block requests with suspicious user-agent patterns
SecRule REQUEST_HEADERS:User-Agent "@rx (sqlmap|nikto|nessus|masscan|zgrab)" \
"id:1010, phase:1, deny, status:403, \
log, msg:'Blocked known attack tool user-agent', \
severity:'CRITICAL', tag:'custom/attack-tool'"
# Custom rule: block access to sensitive files
SecRule REQUEST_URI "@rx \.(env|git|svn|htpasswd|htaccess|bak|old|sql|tar\.gz|zip)$" \
"id:1011, phase:1, deny, status:403, \
log, msg:'Blocked access to sensitive file type', \
severity:'CRITICAL', tag:'custom/sensitive-file'"
# Custom rule: geo-block or flag specific patterns
SecRule REQUEST_URI "@contains /xmlrpc.php" \
"id:1012, phase:1, deny, status:403, \
log, msg:'Blocked xmlrpc.php access', \
severity:'WARNING', tag:'custom/xmlrpc-block'"
To identify which rules are causing false positives, analyze the audit log:
# Find most frequently triggered rules
grep -oP 'id "\K[0-9]+' /var/log/modsecurity/audit.log | \
sort | uniq -c | sort -rn | head -20
# Find which URIs trigger the most blocks
grep -oP 'uri "\K[^"]+' /var/log/modsecurity/audit.log | \
sort | uniq -c | sort -rn | head -20
# See full details for a specific rule ID
grep 'id "942100"' /var/log/modsecurity/audit.log | tail -5
A WAF blocks malicious payloads. Rate limiting blocks abusive volume. Together they cover both quality and quantity of attack traffic.
Nginx Rate Limiting:
# /etc/nginx/nginx.conf — inside http block
# Define rate limit zones
# General: 10 requests/second per IP
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
# Login endpoints: 5 requests/minute per IP (brute force protection)
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
# API endpoints: 30 requests/second per IP
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
# Connection limits: max 20 concurrent connections per IP
limit_conn_zone $binary_remote_addr zone=connlimit:10m;
# Custom error page for rate-limited requests
limit_req_status 429;
limit_conn_status 429;
# /etc/nginx/sites-available/yoursite.conf — inside server block
server {
listen 443 ssl http2;
server_name example.com;
# Global connection limit
limit_conn connlimit 20;
# General rate limit with burst allowance
location / {
limit_req zone=general burst=20 nodelay;
proxy_pass http://127.0.0.1:8080;
}
# Strict rate limit on login — 5/min, burst of 3
location /login {
limit_req zone=login burst=3 nodelay;
proxy_pass http://127.0.0.1:8080;
}
location /wp-login.php {
limit_req zone=login burst=3 nodelay;
proxy_pass http://127.0.0.1:8080;
}
# API with higher limit but still bounded
location /api/ {
limit_req zone=api burst=50 nodelay;
proxy_pass http://127.0.0.1:8080;
}
}
The burst parameter allows short traffic spikes without blocking legitimate users. nodelay processes burst requests immediately rather than queuing them. Without nodelay, excess requests within the burst window are delayed — useful for API endpoints where you want to slow clients down rather than reject them.
Apache Rate Limiting with mod_evasive:
# Install mod_evasive
sudo apt install -y libapache2-mod-evasive
sudo a2enmod evasive
# /etc/apache2/mods-enabled/evasive.conf
<IfModule mod_evasive20.c>
# Max requests per page per interval (per IP)
DOSHashTableSize 3097
DOSPageCount 5
DOSSiteCount 50
DOSPageInterval 1
DOSSiteInterval 1
DOSBlockingPeriod 60
# Email notification (optional)
DOSEmailNotify admin@example.com
# Log directory
DOSLogDir "/var/log/mod_evasive"
# Whitelist trusted IPs
DOSWhitelist 127.0.0.1
DOSWhitelist 10.0.0.*
</IfModule>
For Apache 2.4+, you can also use mod_ratelimit for bandwidth throttling:
# Limit download speed for large files
<Location "/downloads">
SetOutputFilter RATE_LIMIT
SetEnv rate-limit 512
SetEnv rate-initial-burst 2048
</Location>
SYN floods target the TCP handshake, exhausting server resources before any HTTP request reaches your WAF. Defense happens at the kernel level through sysctl parameters and iptables rules.
# /etc/sysctl.d/99-syn-flood-protection.conf
# Enable SYN cookies — the single most important setting
# When SYN backlog is full, use cryptographic cookies instead of
# allocating resources for half-open connections
net.ipv4.tcp_syncookies = 1
# Increase SYN backlog — how many half-open connections to queue
# Default 128 is far too low for any production server
net.ipv4.tcp_max_syn_backlog = 65536
# Increase the listen() backlog
net.core.somaxconn = 65536
# Reduce SYN-ACK retries — faster cleanup of dead connections
# Default is 5 (roughly 3 minutes). Set to 2 (~15 seconds)
net.ipv4.tcp_synack_retries = 2
# Reduce TIME_WAIT recycling
net.ipv4.tcp_tw_reuse = 1
# Reduce FIN timeout
net.ipv4.tcp_fin_timeout = 15
# Increase conntrack table size for high-traffic servers
net.netfilter.nf_conntrack_max = 1048576
net.netfilter.nf_conntrack_tcp_timeout_syn_sent = 30
net.netfilter.nf_conntrack_tcp_timeout_syn_recv = 15
# Disable source routing and redirects
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
# Enable reverse path filtering
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
# Log martian packets (packets with impossible addresses)
net.ipv4.conf.all.log_martians = 1
# Apply changes
# sudo sysctl --system
Add iptables rules for SYN rate limiting as a second layer:
# SYN flood protection with iptables
# Create a chain for SYN flood protection
sudo iptables -N SYN_FLOOD
sudo iptables -A INPUT -p tcp --syn -j SYN_FLOOD
# Limit SYN packets: 100/second with burst of 150
sudo iptables -A SYN_FLOOD -m limit --limit 100/s --limit-burst 150 -j RETURN
sudo iptables -A SYN_FLOOD -j DROP
# Limit new connections per IP: 30/minute
sudo iptables -A INPUT -p tcp --dport 443 -m conntrack --ctstate NEW \
-m recent --set --name HTTPS
sudo iptables -A INPUT -p tcp --dport 443 -m conntrack --ctstate NEW \
-m recent --update --seconds 60 --hitcount 30 --name HTTPS -j DROP
# Drop invalid packets
sudo iptables -A INPUT -m conntrack --ctstate INVALID -j DROP
# Drop XMAS and NULL packets (port scanning)
sudo iptables -A INPUT -p tcp --tcp-flags ALL ALL -j DROP
sudo iptables -A INPUT -p tcp --tcp-flags ALL NONE -j DROP
# Save rules
sudo iptables-save | sudo tee /etc/iptables/rules.v4
Fail2ban monitors log files and automatically bans IPs that exhibit malicious behavior. Integrating it with ModSecurity creates an automated response loop: the WAF detects the attack, Fail2ban bans the attacker.
# Install Fail2ban
sudo apt install -y fail2ban
# Create ModSecurity jail
# /etc/fail2ban/jail.d/modsecurity.conf
[modsecurity]
enabled = true
filter = modsecurity
logpath = /var/log/modsecurity/audit.log
maxretry = 3
findtime = 600
bantime = 3600
action = iptables-multiport[name=modsecurity, port="80,443", protocol=tcp]
# Aggressive repeat offender jail — longer ban
[modsecurity-repeat]
enabled = true
filter = modsecurity
logpath = /var/log/modsecurity/audit.log
maxretry = 10
findtime = 3600
bantime = 86400
action = iptables-multiport[name=modsecurity-repeat, port="80,443", protocol=tcp]
# Rate limit abuse jail (Nginx 429s)
[nginx-limit-req]
enabled = true
filter = nginx-limit-req
logpath = /var/log/nginx/error.log
maxretry = 10
findtime = 120
bantime = 3600
action = iptables-multiport[name=nginx-ratelimit, port="80,443", protocol=tcp]
Create the ModSecurity filter:
# /etc/fail2ban/filter.d/modsecurity.conf
[Definition]
failregex = ^.*\[client \].*ModSecurity:.*\[severity "(?:CRITICAL|ERROR|WARNING)"\].*$
^.*\bclient: \b.*ModSecurity:.*\[severity "(?:CRITICAL|ERROR|WARNING)"\].*$
^\[.*\]\s+\s+.*-\s+".*"\s+\d+\s+.*id\s+"9\d+".*$
ignoreregex =
datepattern = ^[^\[]*\[({DATE})
{^LN-BEG}
Add a progressive ban strategy — escalating ban times for repeat offenders:
# /etc/fail2ban/jail.d/recidive.conf
[recidive]
enabled = true
filter = recidive
logpath = /var/log/fail2ban.log
maxretry = 5
findtime = 86400
bantime = 604800
action = iptables-allports[name=recidive]
# This bans any IP that gets banned 5 times in 24 hours
# for an entire week across ALL ports
# Restart Fail2ban and verify
sudo systemctl restart fail2ban
sudo fail2ban-client status
sudo fail2ban-client status modsecurity
# Check currently banned IPs
sudo fail2ban-client status modsecurity | grep "Banned IP"
# Manually unban an IP if needed
sudo fail2ban-client set modsecurity unbanip 192.168.1.100
Security headers instruct the browser to enforce security policies on the client side. They do not replace server-side protection but add defense in depth against XSS, clickjacking, MIME sniffing, and data exfiltration.
Nginx security headers:
# /etc/nginx/conf.d/security-headers.conf
# Prevent clickjacking
add_header X-Frame-Options "SAMEORIGIN" always;
# Prevent MIME type sniffing
add_header X-Content-Type-Options "nosniff" always;
# XSS protection (legacy, but still useful for older browsers)
add_header X-XSS-Protection "1; mode=block" always;
# Referrer policy — send origin only for cross-origin requests
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# HSTS — force HTTPS for 1 year, include subdomains
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Content Security Policy — adjust to your application
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'self';" always;
# Permissions Policy — disable unnecessary browser features
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
# Hide server version
server_tokens off;
# Hide Nginx version from error pages
more_clear_headers Server;
Apache security headers:
# /etc/apache2/conf-available/security-headers.conf
<IfModule mod_headers.c>
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self';"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
# Remove server information headers
Header always unset X-Powered-By
Header always unset Server
</IfModule>
# Disable server signature and version
ServerTokens Prod
ServerSignature Off
# Enable headers module
# sudo a2enmod headers
# sudo a2enconf security-headers
Your WAF inspects decrypted traffic, so TLS termination happens before the WAF sees the request. A weak TLS configuration undermines everything else — if an attacker can downgrade the connection or exploit a cipher vulnerability, your WAF never gets to inspect the traffic.
Nginx TLS configuration:
# /etc/nginx/conf.d/ssl.conf
# Only TLS 1.2 and 1.3 — disable everything older
ssl_protocols TLSv1.2 TLSv1.3;
# Modern cipher suite — no weak ciphers
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305';
# Server cipher preference
ssl_prefer_server_ciphers on;
# Session caching for performance
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# OCSP stapling — prove certificate validity without client lookup
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# DH parameters — generate your own
# openssl dhparam -out /etc/nginx/dhparam.pem 4096
ssl_dhparam /etc/nginx/dhparam.pem;
# Early data (0-RTT) — disable to prevent replay attacks
ssl_early_data off;
Apache TLS configuration:
# /etc/apache2/mods-enabled/ssl.conf
SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305
SSLHonorCipherOrder on
SSLCompression off
SSLSessionTickets off
# OCSP stapling
SSLUseStapling On
SSLStaplingResponderTimeout 5
SSLStaplingReturnResponderErrors off
SSLStaplingCache shmcb:/var/run/ocsp(128000)
A WAF you have not tested is a WAF you cannot trust. Verify that rules are actually blocking attacks before considering your deployment complete.
# Test SQL injection blocking
curl -v "https://example.com/?id=1%20UNION%20SELECT%20username,password%20FROM%20users"
# Expected: HTTP 403
# Test XSS blocking
curl -v "https://example.com/?q=<script>alert('xss')</script>"
# Expected: HTTP 403
# Test path traversal blocking
curl -v "https://example.com/../../etc/passwd"
# Expected: HTTP 403
# Test command injection blocking
curl -v "https://example.com/?cmd=;cat%20/etc/passwd"
# Expected: HTTP 403
# Test sensitive file access blocking
curl -v "https://example.com/.env"
# Expected: HTTP 403
# Test rate limiting (send 20 rapid requests)
for i in $(seq 1 20); do
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/
done
# Expected: mix of 200s then 429s
# Verify security headers
curl -sI https://example.com | grep -iE "(x-frame|x-content|strict-transport|content-security|referrer-policy|permissions-policy|server)"
# Full scan with Nikto (install: apt install nikto)
nikto -h https://example.com -ssl
# OWASP ZAP automated scan (if available)
# zap-cli quick-scan -s xss,sqli https://example.com
Check your TLS configuration grade:
# Online: https://www.ssllabs.com/ssltest/
# CLI alternative with testssl.sh
git clone --depth 1 https://github.com/drwetter/testssl.sh.git
cd testssl.sh
./testssl.sh https://example.com
Deploying a WAF is not a one-time task. Attack patterns evolve. Applications change. Rules need updates. Build monitoring into your workflow:
# Daily WAF summary script — add to cron
#!/bin/bash
# /opt/scripts/waf-daily-summary.sh
LOG="/var/log/modsecurity/audit.log"
DATE=$(date -d "yesterday" +%Y-%m-%d)
echo "=== WAF Summary for $DATE ==="
echo -e "\nTotal blocked requests:"
grep "$DATE" "$LOG" | grep -c "Action: Intercepted"
echo -e "\nTop 10 blocked IPs:"
grep "$DATE" "$LOG" | grep -oP 'client: \K[0-9.]+' | \
sort | uniq -c | sort -rn | head -10
echo -e "\nTop 10 triggered rules:"
grep "$DATE" "$LOG" | grep -oP 'id "\K[0-9]+' | \
sort | uniq -c | sort -rn | head -10
echo -e "\nAttack categories:"
grep "$DATE" "$LOG" | grep -oP 'tag "\K[^"]+' | \
sort | uniq -c | sort -rn | head -10
echo -e "\nFail2ban status:"
sudo fail2ban-client status modsecurity 2>/dev/null
echo -e "\nCurrent iptables DROP rules:"
sudo iptables -L -n | grep DROP | wc -l
Keep OWASP CRS updated — new rules are released regularly to address emerging attack patterns:
# Update OWASP CRS
cd /etc/modsecurity/crs
sudo git fetch origin
sudo git pull origin main
# Review changelog for breaking changes before applying
# Test in DetectionOnly mode first
# sudo systemctl reload nginx
Before considering your WAF deployment production-ready, verify every item on this checklist:
✅ ModSecurity installed and loading (check with nginx -t or apachectl -M)
✅ OWASP CRS v4 installed and all rule files loading
✅ SecRuleEngine set to On (not DetectionOnly)
✅ Audit logging enabled and writing to /var/log/modsecurity/audit.log
✅ Log rotation configured for audit logs (they grow fast)
✅ Paranoia level and anomaly threshold configured appropriately
✅ False positives identified and exclusion rules added
✅ Rate limiting configured for login and API endpoints
✅ SYN flood sysctl parameters applied
✅ Fail2ban jails active for ModSecurity and rate limit logs
✅ Security headers verified with curl -sI
✅ TLS 1.2/1.3 only, strong ciphers, OCSP stapling enabled
✅ Server tokens and version strings hidden
✅ SQL injection, XSS, and path traversal tests confirmed blocked
✅ Legitimate application functionality verified (no false positive blocks)
✅ Monitoring script or dashboard configured
✅ CRS update process documented
With everything configured — ModSecurity + OWASP CRS, rate limiting, SYN flood protection, Fail2ban, security headers, and TLS hardening — you have a defense stack that covers the attack surface from Layer 3 through Layer 7:
Layer 3-4 (Network/Transport): SYN flood protection via sysctl and iptables. Connection rate limiting. Invalid packet dropping. Source route and redirect blocking.
Layer 7 (Application): ModSecurity inspecting every HTTP request against 200+ OWASP CRS rules. SQL injection, XSS, RCE, LFI/RFI, protocol violations — all caught before they reach your application.
Behavioral: Rate limiting catches volumetric abuse. Fail2ban automatically bans repeat offenders with escalating penalties. The recidive jail permanently (weekly) bans persistent attackers.
Client-side: Security headers enforce browser-side protections — HSTS prevents downgrade attacks, CSP limits script execution, X-Frame-Options blocks clickjacking.
This stack will not stop a determined, funded attacker targeting your specific application with zero-day exploits. Nothing will. But it eliminates the automated, opportunistic, and semi-sophisticated attacks that comprise 95%+ of real-world web server threats. The script kiddies running SQLMap, the botnets spraying credential stuffing attacks, the automated scanners probing for known CVEs — all handled automatically, 24/7, without human intervention.
For organizations running production web servers, a properly configured WAF is not optional — it is infrastructure hygiene on par with backups and monitoring. If your dedicated server or VPS serves HTTP traffic, it needs a WAF. The configurations in this guide give you a production-ready starting point. Start with Paranoia Level 1, monitor your audit logs, tune your exclusions, and scale up paranoia as your confidence in the rule set grows.
Every security layer adds processing overhead. ModSecurity inspects every request and — if response body inspection is enabled — every response. The practical impact depends on your rule set size, traffic volume, and server resources.
On a modern dedicated server with adequate CPU, ModSecurity with the full OWASP CRS adds approximately 2-5 milliseconds per request at Paranoia Level 1. At Paranoia Level 3, this can increase to 10-15 milliseconds due to additional regex evaluations. For most web applications, this latency is invisible to end users — well within the noise floor of network round-trip times.
Where performance becomes a concern is on high-traffic servers handling thousands of requests per second. At that scale, the cumulative CPU consumption of ModSecurity's regex engine becomes measurable. Several optimizations help:
Disable response body inspection if you do not need outbound data leakage detection. This cuts processing time roughly in half since the WAF no longer scans response payloads:
# Disable response body scanning for performance
SecResponseBodyAccess Off
Exclude static assets from WAF inspection. CSS, JavaScript, images, and fonts do not carry application-layer attack payloads. Skipping them reduces wasted processing significantly:
# Nginx — bypass WAF for static files
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
modsecurity off;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Apache — bypass WAF for static files
<LocationMatch "\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$">
SecRuleEngine Off
</LocationMatch>
Use PCRE JIT compilation for faster regex matching. ModSecurity v3 supports this natively — ensure your build includes PCRE JIT support:
# Verify PCRE JIT is available
pcre2test -C jit
# Expected: 1 (enabled)
Monitor WAF performance with ModSecurity's built-in timing data. The audit log includes processing time per phase, helping you identify rules that consume disproportionate CPU:
# Check average processing time from audit logs
grep -oP 'Stopwatch: \K[0-9]+' /var/log/modsecurity/audit.log | \
awk '{sum+=$1; count++} END {print "Average: " sum/count/1000 " ms"}'
# Find slowest requests
grep -oP 'Stopwatch: \K[0-9]+' /var/log/modsecurity/audit.log | \
sort -rn | head -10
The performance trade-off is overwhelmingly worthwhile. A few milliseconds of latency per request is a trivial cost compared to the damage from a successful SQL injection that dumps your user database, or a remote code execution exploit that gives an attacker shell access to your server. If you are running a VPS or dedicated server that serves any kind of dynamic content — WordPress, a web application, an API — the WAF overhead is negligible relative to your application's own processing time.
The best WAF is the one that runs. Deploy it today.