ModSecurity is an open source web application firewall (WAF) that runs inside your web server and inspects every HTTP request before it reaches your application. Paired with the OWASP Core Rule Set (CRS), it detects and blocks common attacks such as SQL injection, cross-site scripting (XSS), local file inclusion and remote command execution. In this tutorial you will install ModSecurity and the CRS from the Ubuntu 24.04 repositories on either Apache or Nginx, run it in detection mode, switch it to blocking, and learn how to handle false positives.

Prerequisites

To follow this tutorial, you will need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has sudo privileges.
  • Apache or Nginx installed from the Ubuntu repositories (sudo apt install apache2 or sudo apt install nginx) and serving at least one site.
  • Ports 80 and 443 open in your firewall.

Ubuntu ships two different engines, and the configuration differs slightly between them:

Web serverPackageEngineMain config file
Apachelibapache2-mod-security2ModSecurity 2.9/etc/modsecurity/modsecurity.conf
Nginxlibnginx-mod-http-modsecuritylibmodsecurity 3/etc/nginx/modsecurity.conf

Both use the same CRS package (modsecurity-crs, version 3.3), which installs the rules in /usr/share/modsecurity-crs/rules/ and its settings in /etc/modsecurity/crs/.

Follow Step 1A for Apache or Step 1B for Nginx, then continue with Step 2.

Step 1A - Installing ModSecurity on Apache

Install the Apache module together with the Core Rule Set:

sudo apt update
sudo apt install libapache2-mod-security2 modsecurity-crs

The package enables the security2 module automatically (along with unique_id, which it depends on). Confirm that Apache loaded it:

sudo apache2ctl -M | grep security
 security2_module (shared)

The package ships only a template of the engine configuration. Copy it into place so that it is picked up by /etc/apache2/mods-available/security2.conf, which includes every *.conf file in /etc/modsecurity/ and then loads the CRS through /usr/share/modsecurity-crs/owasp-crs.load:

sudo cp /etc/modsecurity/modsecurity.conf-recommended /etc/modsecurity/modsecurity.conf

Check the syntax and restart Apache:

sudo apache2ctl configtest
sudo systemctl restart apache2
Syntax OK

On Apache, the engine settings live in /etc/modsecurity/modsecurity.conf and the audit log is written to /var/log/apache2/modsec_audit.log. Skip to Step 2.

Step 1B - Installing ModSecurity on Nginx

Install the Nginx connector and the Core Rule Set:

sudo apt update
sudo apt install libnginx-mod-http-modsecurity modsecurity-crs

The package loads the module through /etc/nginx/modules-enabled/50-mod-http-modsecurity.conf and installs two configuration files: /etc/nginx/modsecurity.conf (engine settings) and /etc/nginx/modsecurity_includes.conf (the list of rule files to load).

libmodsecurity 3 does not support the IncludeOptional directive used in the CRS load file, so list the CRS files explicitly instead. Open the includes file:

sudo nano /etc/nginx/modsecurity_includes.conf

Replace its contents with:

include modsecurity.conf
include /etc/modsecurity/crs/crs-setup.conf
include /etc/modsecurity/crs/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf
include /usr/share/modsecurity-crs/rules/*.conf
include /etc/modsecurity/crs/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf

The first line loads /etc/nginx/modsecurity.conf (paths are relative to the includes file). The order matters: CRS settings first, then your exclusions, then the rules, then the exclusions that must run after the rules.

Now enable ModSecurity in the server block of the site you want to protect, for example /etc/nginx/sites-available/default:

sudo nano /etc/nginx/sites-available/default

Add the two directives inside the server block:

server {
    listen 80 default_server;
    listen [::]:80 default_server;

    root /var/www/html;
    index index.html index.htm index.nginx-debian.html;
    server_name _;

    modsecurity on;
    modsecurity_rules_file /etc/nginx/modsecurity_includes.conf;

    location / {
        try_files $uri $uri/ =404;
    }
}

You can also place the two lines in the http block of /etc/nginx/nginx.conf to protect every site at once. Test the configuration and reload Nginx:

sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

On Nginx, the engine settings live in /etc/nginx/modsecurity.conf and the audit log is written to /var/log/nginx/modsec_audit.log.

Step 2 - Testing in detection mode

Both packages start with SecRuleEngine DetectionOnly: ModSecurity evaluates every rule and logs matches, but never blocks. This is the safe way to start, because you can see what the CRS would block on your real traffic before it affects visitors.

Send a request that contains an obvious XSS payload:

curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost/?q=%3Cscript%3Ealert(1)%3C/script%3E'
200

The request still succeeds, but it was recorded. Look at the end of the audit log (use /var/log/nginx/modsec_audit.log on Nginx):

sudo tail -n 30 /var/log/apache2/modsec_audit.log

You will see entries mentioning the matched rules, similar to:

Message: Warning. detected XSS using libinjection. [file "/usr/share/modsecurity-crs/rules/REQUEST-941-APPLICATION-ATTACK-XSS.conf"] [line "39"] [id "941100"] [msg "XSS Attack Detected via libinjection"]
Message: Warning. Operator GE matched 5 at TX:anomaly_score. [file "/usr/share/modsecurity-crs/rules/REQUEST-949-BLOCKING-EVALUATION.conf"] [line "80"] [id "949110"] [msg "Inbound Anomaly Score Exceeded (Total Score: 15)"]

The CRS works with anomaly scoring: each matching rule adds points to the request, and rule 949110 blocks the request once the total reaches the inbound threshold (5 by default). Leave the server in this mode for a few days while you use every part of your application (logins, forms, uploads, admin panel, API calls), then review which rule IDs fired on legitimate requests. This command lists the rule IDs that matched most often:

sudo grep -o 'id "[0-9]*"' /var/log/apache2/modsec_audit.log | sort | uniq -c | sort -rn | head -20

Rule IDs 949110 and 980130 (the scoring and correlation rules) appear on every request that reaches the threshold; the interesting ones are the individual rules listed around them.

Step 3 - Handling false positives

When a legitimate request triggers a rule, do not disable the whole CRS. Write a narrow exclusion that removes one rule for one path or parameter. The CRS package provides a dedicated file for this, loaded before the rules on both Apache and Nginx:

sudo nano /etc/modsecurity/crs/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf

Add your exclusions at the end of the file. Each rule needs a unique id; the range 1 to 99,999 is reserved for local rules. The following examples cover the most common cases:

# Rich-text editor posts HTML in the "content" field: stop XSS rules from inspecting only that field
SecRule REQUEST_URI "@beginsWith /admin/posts" \
    "id:1000,phase:1,pass,nolog,ctl:ruleRemoveTargetByTag=attack-xss;ARGS:content"

# Remove a single rule for a single endpoint
SecRule REQUEST_URI "@beginsWith /api/upload" \
    "id:1001,phase:1,pass,nolog,ctl:ruleRemoveById=920420"

# Skip the WAF entirely for a trusted monitoring IP
SecRule REMOTE_ADDR "@ipMatch 203.0.113.10" \
    "id:1002,phase:1,pass,nolog,ctl:ruleEngine=Off"

Replace the paths, the rule ID 920420 and the IP address 203.0.113.10 with the values you found in your audit log. Prefer ruleRemoveTargetById or ruleRemoveTargetByTag over removing a whole rule, since they keep the protection for every other parameter.

If you run a common application, the CRS already includes exclusion packages for WordPress, Drupal, Nextcloud, DokuWiki, cPanel and XenForo. Enable the one you need by uncommenting rule 900130 in /etc/modsecurity/crs/crs-setup.conf and setting, for example, setvar:tx.crs_exclusions_wordpress=1.

Apply the changes with sudo apache2ctl configtest && sudo systemctl reload apache2 or sudo nginx -t && sudo systemctl reload nginx.

Step 4 - Enabling blocking mode

Once your normal traffic no longer produces matches, switch the engine to blocking. On Apache:

sudo sed -i 's/^SecRuleEngine DetectionOnly/SecRuleEngine On/' /etc/modsecurity/modsecurity.conf
sudo apache2ctl configtest && sudo systemctl reload apache2

On Nginx:

sudo sed -i 's/^SecRuleEngine DetectionOnly/SecRuleEngine On/' /etc/nginx/modsecurity.conf
sudo nginx -t && sudo systemctl reload nginx

Confirm the change:

grep -E '^SecRuleEngine' /etc/modsecurity/modsecurity.conf /etc/nginx/modsecurity.conf 2>/dev/null
/etc/modsecurity/modsecurity.conf:SecRuleEngine On

Now repeat the attack requests. An XSS attempt and a command injection attempt should both be rejected:

curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost/?q=%3Cscript%3Ealert(1)%3C/script%3E'
curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost/?exec=/bin/bash'
403
403

A normal request must still return 200:

curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost/'
200

Step 5 - Adjusting the paranoia level and anomaly threshold

The CRS defaults (paranoia level 1, inbound anomaly threshold 5) block most automated attacks with very few false positives, and they are the right choice for most sites. Both values are set in /etc/modsecurity/crs/crs-setup.conf through rules 900000 and 900110, which are commented out by default.

sudo nano /etc/modsecurity/crs/crs-setup.conf

To raise the paranoia level to 2 (more rules, more false positives, better coverage), uncomment rule 900000 and change the value:

SecAction \
  "id:900000,\
   phase:1,\
   nolog,\
   pass,\
   t:none,\
   setvar:tx.paranoia_level=2"

To make blocking less aggressive while you tune a new application, uncomment rule 900110 and raise the inbound threshold, for example to 10:

SecAction \
 "id:900110,\
  phase:1,\
  nolog,\
  pass,\
  t:none,\
  setvar:tx.inbound_anomaly_score_threshold=10,\
  setvar:tx.outbound_anomaly_score_threshold=4"

Reload the web server after each change and go back to detection mode for a while if you raise the paranoia level, since level 2 and above will need extra exclusions.

Step 6 - Keeping the audit log under control

With SecAuditEngine RelevantOnly (the default), ModSecurity writes a full entry, including request headers and body parts, for every request that triggers a warning or error. On a public server that is scanned constantly this file grows quickly, so rotate it with logrotate:

sudo nano /etc/logrotate.d/modsecurity

For Apache:

/var/log/apache2/modsec_audit.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    copytruncate
}

For Nginx, use /var/log/nginx/modsec_audit.log as the path. copytruncate keeps the web server writing to the same file handle without a reload. Check the configuration with a dry run:

sudo logrotate -d /etc/logrotate.d/modsecurity

Because the audit log can contain submitted form data, keep it readable only by root and the adm group, which is the default for both packages.

Troubleshooting

Apache fails to start with Could not open configuration file /etc/modsecurity/crs/crs-setup.conf. The modsecurity-crs package is missing or its files were removed. Reinstall it with sudo apt install --reinstall modsecurity-crs.

nginx -t reports unknown directive "modsecurity". The module is not loaded. Check that /etc/nginx/modules-enabled/50-mod-http-modsecurity.conf exists and that you are running the Ubuntu nginx package (apt policy nginx).

nginx -t fails with an error about IncludeOptional. You are including /usr/share/modsecurity-crs/owasp-crs.load directly. Use the explicit list of include lines from Step 1B instead.

Legitimate users get 403 errors after enabling blocking. Find the rule in the audit log (search for the client IP or the URL), add an exclusion as shown in Step 3, and reload. If the problem is widespread, set SecRuleEngine DetectionOnly again while you tune.

Large uploads fail with 413. ModSecurity rejects request bodies larger than SecRequestBodyLimit (13107200 bytes, 12.5 MB, by default). Raise the value in the engine config file if your application accepts bigger uploads.

Conclusion

Your web server now inspects every request with ModSecurity and the OWASP Core Rule Set, blocks common injection and XSS attacks, and logs the details of each blocked request. The key to running a WAF in production is the tuning loop: detection mode, review, narrow exclusions, then blocking. As next steps, forward the audit log to a central log system, protect login endpoints with rate limiting in the web server or Fail2ban, and review the audit log after each application release to catch new false positives early.