Configure a WAF in front of Nginx without blocking real users
For developers putting a web application firewall in front of an existing app, this shows a safe rollout that blocks obvious junk without breaking logins, APIs, webhooks, or uploads. You’ll finish with IP restoration, trusted proxy config, rate limits, allowlists, and repeatable verification commands.
TL;DR — Put the WAF in detection/log-only mode first, restore the real client IP at Nginx, and explicitly exempt login callbacks, API clients, webhooks, and large uploads before enabling blocking. The most common cause of false positives is rate limiting or geo/IP rules applied before
real_ip_header/trusted proxy settings are correct. Reading time: ~5 min
Goal
When you are done, your application is reachable through the WAF, Nginx logs the real client IP instead of the proxy IP, obvious abusive traffic is rate-limited or blocked, and legitimate browser users, API clients, OAuth callbacks, webhooks, and file uploads still complete successfully.
Prerequisites
- SSH access to the app host with sudo/root on the reverse proxy server
- Nginx installed, version 1.20+; check with:
nginx -v
- Your app already works directly behind Nginx before adding WAF protection
- Access to your WAF provider dashboard and the current list of its proxy IP ranges/CIDRs
- Public hostname to protect, for example
app.example.com - Ability to test from at least two source IPs: your workstation and a mobile hotspot/VPN exit
curl7.76+; check with:
curl --version | head -n1
- Optional but useful:
jq1.6+ for log/API parsing; check with:
jq --version
- A list of endpoints that must never be challenged or rate-limited: OAuth/OIDC callback, payment webhook, CI/CD webhook, health check, large upload path, API endpoints used by server-to-server clients
Steps
Step 1: Put the WAF in detection/log-only mode
In your provider dashboard, create the site/property for app.example.com, enable the managed rule set, and set the enforcement action to the provider's non-blocking mode. Typical menu paths are:
- Your provider dashboard → Sites/Applications →
app.example.com→ WAF → Managed rules → Action:LogorSimulate - Your provider dashboard → Security → Bot/Rate limiting → Action:
LogorCount
What you should see when this succeeds: requests still reach the app normally, and the provider dashboard starts showing matched rules/events without returning 403/429 to users.
Step 2: Restore the real client IP in Nginx
⚠️ If you trust the wrong proxy CIDRs here, clients can spoof their source IP with
X-Forwarded-For. Only trust the published CIDRs of your WAF or load balancer.
Create a dedicated include file with trusted proxy ranges and the header your WAF sends. Replace the example CIDRs with your provider's actual published ranges.
sudo tee /etc/nginx/conf.d/real-ip-waf.conf >/dev/null <<'EOF'
real_ip_header X-Forwarded-For;
real_ip_recursive on;
set_real_ip_from 203.0.113.0/24;
set_real_ip_from 198.51.100.0/24;
set_real_ip_from 2001:db8:100::/48;
EOF
sudo nginx -t && sudo systemctl reload nginx
What you should see when this succeeds:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Step 3: Log enough detail to diagnose false positives
Add a log format that records the restored client IP, forwarded chain, request time, upstream status, and user agent.
sudo tee /etc/nginx/conf.d/log-format-waf.conf >/dev/null <<'EOF'
log_format waf '$remote_addr - $host [$time_local] '
'"$request" $status $body_bytes_sent '
'rt=$request_time uct=$upstream_connect_time '
'uht=$upstream_header_time urt=$upstream_response_time '
'xff="$http_x_forwarded_for" ref="$http_referer" '
'ua="$http_user_agent"';
access_log /var/log/nginx/access.log waf;
EOF
sudo nginx -t && sudo systemctl reload nginx
What you should see when this succeeds: new lines in /var/log/nginx/access.log start with the real client IP, not the WAF edge IP.
Step 4: Add conservative Nginx rate limits after real IP restoration
Create one general limit and one stricter login limit. These values are intentionally mild for first rollout.
sudo tee /etc/nginx/conf.d/rate-limit.conf >/dev/null <<'EOF'
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
EOF
Then apply them in your server block. Edit your site config and add these exact blocks inside the server {} for app.example.com:
location / {
limit_req zone=perip burst=30 nodelay;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /login {
limit_req zone=login burst=10 nodelay;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Validate and reload:
sudo nginx -t && sudo systemctl reload nginx
What you should see when this succeeds: normal browsing works; deliberate bursts start returning 429 Too Many Requests instead of app errors.
Step 5: Exempt endpoints that commonly break behind WAFs
Add explicit locations for webhook receivers, OAuth callbacks, health checks, and large uploads. Do not apply challenge pages or aggressive rate limits to these.
location = /oauth/callback {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /webhooks/provider {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /healthz {
access_log off;
proxy_pass http://127.0.0.1:3000;
}
location /uploads/ {
client_max_body_size 100m;
proxy_request_buffering off;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Reload:
sudo nginx -t && sudo systemctl reload nginx
In your WAF dashboard, create explicit bypass/allow rules for the same paths and for webhook source IPs if the sender publishes stable ranges. Typical menu path:
- Your provider dashboard → WAF → Custom rules/Exceptions → Add rule
- Match:
Path equals /oauth/callback→ Action:Bypass challenge - Match:
Path equals /webhooks/providerANDSource IP in <published CIDRs>→ Action:Allow
What you should see when this succeeds: OAuth logins return to the app instead of a WAF challenge page, and webhook deliveries stop retrying with 403/429.
Step 6: Point DNS through the WAF and test before enabling blocking
In your DNS provider or WAF DNS section, point app.example.com to the WAF-proxied record for your origin. Exact UI varies, but the literal record should be:
| Type | Name | Value | Proxy |
|---|---|---|---|
| A or CNAME | app | your origin IP or origin hostname | Enabled |
Then test headers and status:
curl -I https://app.example.com/
curl -I https://app.example.com/oauth/callback
curl -I https://app.example.com/healthz
Expected output shape:
HTTP/2 200
server: nginx
content-type: text/html; charset=utf-8
A bad redirect loop looks like this:
curl -I -L --max-redirs 5 https://app.example.com/
curl: (47) Maximum (5) redirects followed
What you should see when this succeeds: the hostname resolves through the WAF, and your key endpoints return 200/302 as expected with no redirect loop.
Step 7: Turn on blocking in stages
In your provider dashboard, change actions in this order:
- Managed rules:
Log→Block - Rate limiting:
Count→BlockorThrottle - Bot challenge/CAPTCHA: enable only on high-risk paths, not globally
Start with one category at a time and wait 15-30 minutes between changes while watching logs.
Use this request flow as the rollout model:
View as text
[Client] ---- 1. GET / ----> [WAF] [WAF] ---- 2. allowed ----> [Nginx] [Nginx] ---- 3. proxy ----> [App] [App] ---- 4. 200/302 ----> [Nginx] [Nginx] ---- 5. response ----> [WAF] [WAF] ---- 6. response ----> [Client]
What you should see when this succeeds: attack traffic starts receiving WAF 403/429 responses, while normal user journeys still complete.
Verify it works
Run these checks from your workstation and a second network:
curl -I https://app.example.com/
curl -I https://app.example.com/login
curl -I https://app.example.com/oauth/callback
curl -I https://app.example.com/healthz
for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code}\n" https://app.example.com/login; done
sudo tail -n 20 /var/log/nginx/access.log
Expected results:
/returns200or302consistently/oauth/callbackreturns the app's normal response, not a WAF challenge page/healthzreturns200- The burst test eventually shows some
429responses, but single requests still return normal codes access.logshows your real public IP in the first field, not the WAF edge IP
A healthy access log line looks like:
198.51.100.23 - app.example.com [30/Oct/2026:14:22:11 +0000] "GET / HTTP/2.0" 200 5120 rt=0.041 uct=0.001 uht=0.012 urt=0.041 xff="198.51.100.23" ref="-" ua="curl/8.5.0"
Common pitfalls
Trusted proxy CIDRs are missing or wrong
Mistake: you enabled rate limits before adding the WAF's published CIDRs to set_real_ip_from.
Symptom: all users appear as one or a few WAF IPs in Nginx logs, and many legitimate requests get 429 Too Many Requests.
Fix: update /etc/nginx/conf.d/real-ip-waf.conf with the provider's current CIDRs, then sudo nginx -t && sudo systemctl reload nginx.
Global challenge/CAPTCHA is applied to callback or webhook paths
Mistake: bot challenge is enabled for every path.
Symptom: OAuth login returns to a challenge page, payment/webhook senders report 403 Forbidden, or retries spike.
Fix: add bypass rules for /oauth/callback, webhook paths, and health checks in the WAF dashboard.
Uploads fail after enabling inspection
Mistake: request body inspection or Nginx defaults are too small for your upload path.
Symptom: users see 413 Request Entity Too Large, stalled uploads, or upstream timeouts on /uploads/.
Fix: set client_max_body_size 100m; and proxy_request_buffering off; on the upload location, and raise the WAF body-size limit for that path if your provider exposes one.
Redirect loop after proxying through the WAF
Mistake: the app thinks requests are plain HTTP because X-Forwarded-Proto is missing or ignored.
Symptom:
curl -I -L --max-redirs 5 https://app.example.com/
curl: (47) Maximum (5) redirects followed
Fix: send proxy_set_header X-Forwarded-Proto $scheme; from Nginx and configure the app/framework to trust proxy headers.
You blocked your own office/VPN egress IP
Mistake: a geo/IP or reputation rule caught your team network.
Symptom: only staff or CI runners get immediate 403 Forbidden, while public users are fine.
Fix: add an allow rule for office/VPN/CI egress IPs in the WAF dashboard, then retest from a second network to confirm the issue is source-IP specific.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI