Rate Limiting & Security Headers
Once your site is live on the internet, it will get attacked. Bots will hammer your login page trying to guess passwords (a “brute-force attack”), and browsers will happily run malicious scripts if you let them. Nginx (a popular web server, pronounced “engine-x”) gives you two cheap, powerful defenses you can turn on in minutes: rate limiting (slowing down clients that send too many requests) and security headers (small instructions you send to the browser telling it how to behave safely). This page shows you exactly how to configure both on Ubuntu 22.04/24.04 LTS.
What is rate limiting and when to use it
Rate limiting means: “no single client (usually identified by their IP address) may send more than X requests per second.” If they go over, Nginx rejects the extra requests with an error instead of passing them to your application.
When to use it: any endpoint that is expensive or sensitive. The classic examples are a login form (/login), a password-reset page, a sign-up form, an API, or a search box. These are the doors attackers push on.
When NOT to use it (or use it loosely): static assets like images, CSS, and JavaScript. A normal page load pulls in dozens of these at once, so a tight limit would block real visitors. Keep aggressive limits scoped to the dangerous endpoints, not the whole site.
How limit_req_zone and limit_req work
Rate limiting in Nginx is two pieces working together:
| Directive | Where it goes | What it does |
|---|---|---|
limit_req_zone | inside the http {} block (global) | Defines a shared memory “zone” that tracks request counts per key (e.g. per IP), and sets the allowed rate. |
limit_req | inside a server {} or location {} block | Actually applies the zone to specific URLs, with an optional burst. |
You define the rule once globally, then apply it wherever you want.
Step 1 — define the zone
Open the main Nginx config file:
sudo nano /etc/nginx/nginx.conf
Inside the http { ... } block, add a zone:
http {
# Track each client IP. Allow 5 requests/second. Reserve 10 MB of memory.
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/s;
# ... the rest of the existing http block stays as-is ...
}
What each part means:
$binary_remote_addr— the client’s IP address, stored in a compact binary form. This is the “key” we count by.zone=login_limit:10m— names the zonelogin_limitand gives it 10 megabytes of shared memory. That’s enough to track roughly 160,000 IP addresses.rate=5r/s— the steady allowed rate: 5 requests per second per IP. You can also user/mfor per-minute (e.g.rate=30r/m).
Step 2 — apply the zone to your login endpoint
Now edit your site’s config in /etc/nginx/sites-available/. Apply the limit only to the login location:
server {
listen 80;
server_name example.com;
location /login {
# Apply the zone. Allow short bursts of up to 10 queued requests,
# and serve them without delay until the burst is used up.
limit_req zone=login_limit burst=10 nodelay;
proxy_pass http://127.0.0.1:3000;
}
location / {
proxy_pass http://127.0.0.1:3000;
}
}
burst=10— allows a temporary spike: up to 10 extra requests can queue instead of being rejected immediately. Real users sometimes double-click; this stops that from looking like an attack.nodelay— serve those burst requests right away rather than spacing them out. Without it, queued requests are trickled out at therate, which feels slow.
When a client exceeds the limit, Nginx returns HTTP status 503 by default. You can change it to the more standard 429 (Too Many Requests):
http {
limit_req_status 429;
}
Step 3 — test and reload
Always check your config before reloading. A bad config can take your whole site down.
sudo nginx -t
sudo systemctl reload nginx
Output:
nginx: the configuration file /etc/nginx/nginx.conf syntax test is successful
nginx: configuration file /etc/nginx/nginx.conf test is ok
Now hammer the endpoint to confirm it kicks in. Send 20 fast requests:
for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code}\n" http://example.com/login; done
Output:
200
200
200
200
200
200
200
200
200
200
200
429
429
429
429
429
429
429
429
429
The first requests pass (within rate plus burst), then Nginx starts returning 429. Rate limiting works.
Gotcha: if Nginx sits behind another proxy, a load balancer, or Cloudflare, then
$binary_remote_addrmay be the proxy’s IP, not the real visitor’s. Every request would share one bucket and you’d block everyone at once. In that case configure thereal_ipmodule so Nginx reads the true client IP from theX-Forwarded-Forheader first.
Security headers and what each one protects
Security headers are extra lines Nginx adds to every HTTP response. The browser reads them and changes its behavior to be safer. Add them inside a server {} block (or in http {} to apply site-wide):
server {
# Stop your pages being framed by other sites (clickjacking defense).
add_header X-Frame-Options "SAMEORIGIN" always;
# Stop the browser guessing file types (MIME-sniffing defense).
add_header X-Content-Type-Options "nosniff" always;
# Force HTTPS for the next 2 years, including subdomains.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# Control how much referrer info leaks to other sites.
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
Here is what each one actually defends against:
| Header | Protects against | Plain-English meaning |
|---|---|---|
X-Frame-Options: SAMEORIGIN | Clickjacking | Another website can’t load your page inside an invisible <iframe> to trick users into clicking things. Only your own domain may frame it. |
X-Content-Type-Options: nosniff | MIME-sniffing attacks | The browser must trust the file type you declare instead of guessing. Stops a file uploaded as an image from being run as JavaScript. |
Strict-Transport-Security (HSTS) | Downgrade & cookie-theft attacks | After the first visit, the browser refuses to talk to your site over plain HTTP and always uses encrypted HTTPS. |
Referrer-Policy | Privacy / info leakage | Limits how much of your URL is sent to other sites a user clicks through to. |
Warning: only add the HSTS (
Strict-Transport-Security) header once you have a working HTTPS certificate and have tested it. HSTS tells browsers to only use HTTPS for the duration ofmax-age. If HTTPS later breaks, visitors will be locked out and you cannot easily undo it from the server side.
The always keyword at the end matters: without it, Nginx skips the header on error responses (like 404 or 500 pages), leaving those pages unprotected. Always include always.
Verify your headers
After reloading, check that the headers are really being sent:
curl -I https://example.com
Output:
HTTP/2 200
server: nginx
content-type: text/html
x-frame-options: SAMEORIGIN
x-content-type-options: nosniff
strict-transport-security: max-age=63072000; includeSubDomains
referrer-policy: strict-origin-when-cross-origin
Best practices
- Scope rate limits tightly: apply aggressive limits to
/login,/signup, and API routes, and leave static assets generous or unlimited. - Use
burstwithnodelayso legitimate users with double-clicks or quick retries aren’t punished, while sustained floods are still blocked. - Return
429instead of the default503so clients and monitoring tools correctly understand they were rate-limited. - Always add the
alwayskeyword to security headers so error pages are protected too. - Roll out HSTS carefully: start with a short
max-age(e.g.max-age=300), confirm HTTPS is solid, then raise it to 1-2 years. - Run
sudo nginx -tbefore everysudo systemctl reload nginxso a typo never takes your site offline. - Combine these with a firewall: keep
ufwenabled and only allow ports 80, 443, and SSH.