Installing ModSecurity + OWASP CRS on Nginx: Building an Open-Source WAF for Comprehensive Website Protection

Security tutorial - IT technology blog
Security tutorial - IT technology blog

Last month I wrapped up a pretty nasty incident: a client’s e-commerce website had its entire database dumped by an attacker exploiting an SQL injection vulnerability. The Nginx server was running fine, UFW firewall was fully enabled — and yet it still happened. The reason was frustratingly simple: neither of those things actually “understands” that an HTTP request can carry a malicious payload inside it.

The Real Problem: Why Websites Still Get Attacked Even With a Firewall?

After auditing security on 10+ servers, I noticed they all shared the same blind spot: UFW enabled, fail2ban running, SSH port changed — but nothing inspecting HTTP content. That’s the most dangerous gap, and most people overlook it.

Think of it this way: a network firewall is like a building security guard at the front door — they only check whether you have a valid access card (correct IP, correct port). But they don’t open your bag to check what you’re bringing inside. An attacker can walk right in through port 80/443 with a perfectly valid request, then stuff a payload like '; DROP TABLE users; -- into a URL parameter.

Common attack types that network firewalls are completely powerless against:

  • SQL Injection — injecting SQL code into forms or URLs to access or delete the database
  • Cross-Site Scripting (XSS) — injecting malicious scripts into pages to steal user cookies
  • Path Traversal — reading system files outside the web directory (../../etc/passwd)
  • Remote File Inclusion (RFI) — loading and executing files from an external server

Root Cause Analysis: Missing an Application-Layer Control

The traditional security model only has two layers: a network firewall and application code. When the code has vulnerabilities — and virtually all code does — there’s nothing to stop an attack before it reaches the database.

A Web Application Firewall (WAF) fills exactly that gap. It sits between users and the application, reads every HTTP request, compares it against a ruleset, and blocks anything that looks malicious before the request ever reaches your code.

The most practical benefit: a WAF doesn’t require an immediate code fix. Even if the application still has an SQL injection vulnerability, the WAF will block the payload before it causes damage — buying you time to fix it properly instead of patching things in a panic at 2 AM.

Your Options: Which WAF Should You Choose?

I’ve personally tried all three approaches, and here’s my honest take:

1. Cloud WAF (Cloudflare, AWS WAF, Sucuri) — easy to enable, no server configuration needed, but costs money ($20–$200/month depending on plan), request data passes through a third party, and you’re locked into the provider.

2. Commercial WAF hardware/software — F5, Imperva, Fortiweb. Powerful but enterprise-priced, not suitable for personal VPS or small startups.

3. Self-hosted ModSecurity + OWASP CRS — completely free, runs directly on the Nginx server, full control. This is what I use on most servers after trying all three options above.

ModSecurity is an open-source WAF engine that runs as a module inside Nginx. The OWASP Core Rule Set (CRS) is a ruleset maintained by the global security community — currently over 10,000 rules, continuously updated to cover the latest bypass techniques.

Installing ModSecurity + OWASP CRS on Nginx (Ubuntu 22.04)

Step 1: Install ModSecurity and the Nginx Module

sudo apt update
sudo apt install -y nginx libnginx-mod-security2 git

After installation, enable the module in Nginx:

# Check if the module is already present
ls /etc/nginx/modules-enabled/ | grep security

# If the symbolic link doesn't exist, create it manually
sudo ln -s /usr/share/nginx/modules-available/mod-http-modsecurity.conf \
  /etc/nginx/modules-enabled/50-mod-http-modsecurity.conf

Step 2: Basic ModSecurity Configuration

# Copy the sample configuration file
sudo cp /etc/nginx/modsecurity.conf-recommended /etc/nginx/modsecurity.conf

# Enable blocking mode (default is DetectionOnly — log only, no blocking)
sudo sed -i 's/SecRuleEngine DetectionOnly/SecRuleEngine On/' /etc/nginx/modsecurity.conf

Note: On first deployment, I recommend leaving it in DetectionOnly for a week to check for false positives, then switching to On. Toggle between modes like this:

# Log-only mode (safe for initial setup)
SecRuleEngine DetectionOnly

# Actual blocking mode
SecRuleEngine On

Step 3: Download and Configure OWASP CRS

# Download the latest OWASP CRS
cd /etc/nginx
sudo git clone https://github.com/coreruleset/coreruleset.git modsecurity-crs

# Create the configuration file from the template
sudo cp /etc/nginx/modsecurity-crs/crs-setup.conf.example \
  /etc/nginx/modsecurity-crs/crs-setup.conf

# Create the combined include file
sudo mkdir -p /etc/nginx/modsec
sudo tee /etc/nginx/modsec/main.conf <<'EOF'
Include /etc/nginx/modsecurity.conf
Include /etc/nginx/modsecurity-crs/crs-setup.conf
Include /etc/nginx/modsecurity-crs/rules/*.conf
EOF

Step 4: Enable the WAF in Your Nginx Configuration

Add the following to each server block you want to protect in /etc/nginx/sites-available/your-site.conf:

server {
    listen 80;
    server_name example.com;

    # Enable ModSecurity for this server block
    modsecurity on;
    modsecurity_rules_file /etc/nginx/modsec/main.conf;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
# Test config before reloading
sudo nginx -t

# Reload Nginx
sudo systemctl reload nginx

Testing That the WAF Is Working Correctly

Once installed, test it immediately by sending some classic attack payloads:

# Test SQL Injection
curl -v "http://your-server/?id=1'%20OR%20'1'='1"
# Expected: HTTP 403 Forbidden

# Test XSS
curl -v "http://your-server/?q=<script>alert(1)</script>"
# Expected: HTTP 403 Forbidden

# Test Path Traversal
curl -v "http://your-server/?file=../../etc/passwd"
# Expected: HTTP 403 Forbidden

Check the logs to monitor blocked requests:

# ModSecurity logs are written to Nginx's error log
sudo tail -f /var/log/nginx/error.log | grep -i modsecurity

# Or view the dedicated audit log
sudo tail -f /var/log/modsec_audit.log

Handling False Positives: When the WAF Blocks the Wrong Thing

False positives are the most frustrating part of a fresh deployment. I once ran into a case where a WordPress editor form was getting blocked because a blog post contained HTML code snippets — CRS mistakenly flagged it as XSS.

The fix: disable the specific rule causing the false positive, not the entire CRS. Add the following to /etc/nginx/modsec/main.conf before the rules include line:

# Disable a specific rule by ID (find the ID in the error log)
SecRuleRemoveById 941100 941110

# Or whitelist a specific path
SecRule REQUEST_URI "@beginsWith /wp-admin/post.php" \
  "id:1000,phase:1,pass,nolog,ctl:ruleRemoveById=941100"

My usual process: run DetectionOnly for a week, export the logs, filter for the rule IDs that fire most often on real traffic, then assess each one to determine whether it’s a genuine attack. For sites without complex editor forms, the false positive rate is typically under 1%.

Key Things to Keep in Mind During Operation

  • Update CRS regularly — OWASP releases new rulesets frequently to patch emerging bypasses. Run git pull in the /etc/nginx/modsecurity-crs directory, then reload Nginx.
  • Monitor performance — ModSecurity consumes CPU because it must parse every request. On small servers (<2 vCPU), watch the load after enabling it.
  • Combine with fail2ban — parse ModSecurity logs to ban IPs during persistent attack attempts, rather than just returning 403 each time.
  • Back up your config before making changes — I once accidentally misconfigured main.conf and took the entire site down with 403s, costing 20 minutes to restore from backup.

ModSecurity + OWASP CRS is not a silver bullet — you still need to patch code, update dependencies, and run regular audits. But at zero cost and a few hours of setup, you gain a real defensive layer before you’ve had time to fix every vulnerable line of code in your application. For me, that’s more than enough reason to install it on every server.

Share: