log in
consulting hosting industries the daily tools about contact

Rate limiting at nginx before Laravel ever wakes up

Blocking brute-force attempts at the nginx layer is cheaper and faster than doing it in PHP. Here's the actual config I use for login and API endpoints.

Most Laravel apps I inherit have rate limiting wired up in the application layer — a middleware, a RateLimiter facade call in RouteServiceProvider, maybe a package. That's fine as far as it goes, but it means every blocked request still boots PHP-FPM, hits the framework, hydrates the service container, and burns a worker slot. For a login endpoint getting hammered, that's not a minor thing.

Putting rate limits at the nginx layer means the blocked request dies in ~1ms and never reaches your app. PHP doesn't know it happened. Your workers stay free for real traffic.

This is the config I actually use. I'll show you the login endpoint case and the API case separately, because they have different shapes.

How nginx rate limiting works

nginx's ngx_http_limit_req_module is built-in — no compiling required. The model is a leaky bucket: you define a shared memory zone with a request rate, then apply it to a location. Requests that arrive faster than the rate get delayed or rejected.

Two directives do the work:

  • limit_req_zone — defines the zone (shared memory, key, rate)
  • limit_req — applies it to a location, with optional burst and mode settings

The key can be $binary_remote_addr (per-IP, compact 4 or 16 bytes), or a combination with other variables if you're doing something fancier.

The login endpoint config

Login endpoints get credential stuffing, password spraying, and plain old brute force. A real user doesn't need to attempt login more than a handful of times per minute. I set a tight rate with a small burst and nodelay — so burst attempts don't get queued, they get rejected immediately with a 429.

In /etc/nginx/nginx.conf (or a file you include in the http block):

# Define the zone — 10m holds ~160,000 IPs
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

5 requests per minute per IP. That's one attempt every 12 seconds. Generous enough for a clumsy human, tight enough to make a stuffing attack impractical.

Then in your server block (usually /etc/nginx/sites-available/yourapp):

server {
    listen 80;
    server_name yourapp.com;

    # ... ssl, root, index, etc.

    location = /login {
        limit_req zone=login burst=3 nodelay;
        limit_req_status 429;

        try_files $uri $uri/ /index.php?$query_string;

        fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location / {
        try_files $uri $uri/ /index.php?$query_string;

        fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

burst=3 allows a short spike of 3 extra requests before rejecting. nodelay means those burst slots don't queue — they're processed immediately if a slot's available, rejected if not. Without nodelay, nginx queues burst requests and delays them, which can cause legitimate users to see slow responses instead of a clean 429. I prefer the 429. At least it's honest.

Note I'm using location = /login (exact match) instead of location /login. Exact match is faster to evaluate and ensures I'm not accidentally rate-limiting /login-help or some other route that happens to start with that string.

If Laravel is using a POST-only route for login (which it should be), you could restrict this further:

location = /login {
    limit_except GET POST { deny all; }
    limit_req zone=login burst=3 nodelay;
    limit_req_status 429;
    # ... fastcgi config
}

The API endpoint config

API endpoints have a different profile. Authenticated API calls can be higher volume, but you still want a ceiling. I usually set a separate zone with a higher rate — something like 60 requests per minute (1 req/sec average) — and apply it to the /api/ prefix.

limit_req_zone $binary_remote_addr zone=api:20m rate=60r/m;

20m for the API zone because there tend to be more distinct IPs hitting APIs than login pages.

location /api/ {
    limit_req zone=api burst=20 nodelay;
    limit_req_status 429;

    try_files $uri $uri/ /index.php?$query_string;

    fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
    fastcgi_index index.php;
    fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    include fastcgi_params;
}

Burst of 20 means a client can fire a quick batch without hitting limits, but they can't sustain more than 1 req/sec over time.

If you want different limits for authenticated vs. unauthenticated API traffic, you can key on something like an API token header — but that gets complicated fast. For most of what I build, per-IP is sufficient at the nginx layer, and fine-grained per-user limits live in Laravel's RateLimiter.

Logging what's getting blocked

By default, nginx logs rate-limited requests as errors. You'll see something like:

2025/01/15 14:23:01 [error] 12345#12345: *1234 limiting requests, excess: 3.452 by zone "login", client: 45.33.32.156

That's in your error log. If you want to track this in your access log too (useful for alerting), you can use a conditional log or just pipe the error log into whatever you're using for monitoring. I send nginx logs to a syslog target and have Grafana alerts on spikes in limit_req errors by zone name.

The gotchas that bit me

Proxies and load balancers. If you're behind a load balancer (Cloudflare, AWS ALB, anything), $binary_remote_addr will be the balancer's IP — which means you'd be rate-limiting the balancer itself. You need to use $http_x_forwarded_for or set real_ip_header properly:

# In the http or server block
set_real_ip_from 10.0.0.0/8;  # your load balancer's IP range
real_ip_header X-Forwarded-For;
real_ip_recursive on;

Then $binary_remote_addr will resolve to the actual client IP after the module does its work. Get this wrong and either your rate limiting doesn't work at all (all traffic from one IP = the balancer) or you ban the wrong people.

Shared IPs and NAT. Corporate offices, universities, mobile carriers — lots of users behind a single IP. A 5 req/min login limit might lock out an entire office if five people try to log in during the same minute. I had this happen with a healthcare client whose staff all egressed through a single corporate proxy. The fix was bumping the burst up to 10 and accepting the slightly looser limit, then adding application-layer tracking by username as the tighter backstop.

POST-only endpoints that also serve a GET. Laravel's login route usually responds to both GET (show the form) and POST (submit credentials). If you put the tight 5r/m rate limit on the location without thinking, a user who hits refresh a few times on the login page might get rate-limited before they even try to authenticate. Either exclude GET from the rate limit, or set a higher rate for that location and put the tight limit elsewhere. I typically set the zone on the POST in a separate if block — though nginx's if is famously weird:

location = /login {
    # Apply rate limit only to POST
    limit_req zone=login burst=3 nodelay;
    limit_req_status 429;
    # nginx applies limit_req to all methods; to exempt GET,
    # use limit_except or a map variable approach
    # The cleanest way:
    limit_except GET HEAD { limit_req zone=login burst=3 nodelay; }
}

Actually limit_except inside a location that already has limit_req doesn't stack cleanly. The honest answer is: in practice I leave it covering all methods on /login and set the rate high enough (15r/m) that a human refreshing the form a few times won't hit it. The brute-force patterns are obvious — many attempts from one IP in seconds, not a human clicking refresh.

Zone memory sizing. The 10m in zone=login:10m is shared memory, not per-worker. nginx stores state per IP in ~64 bytes. 10MB handles roughly 160,000 IP states. For most apps that's fine. If you're seeing could not allocate new node in your error logs, bump the zone size.

When I'd reach for this (and when I wouldn't)

I put nginx rate limits on every production app I manage now. Login endpoints always. API prefixes almost always. It's a one-time config investment that stops a whole class of abuse before it generates application load, log noise, or database queries.

I still keep Laravel's RateLimiter in place — it handles per-user limits that nginx can't see, and it returns proper JSON responses with Retry-After headers that API clients expect. nginx and Laravel rate limiting are not mutually exclusive; they work at different layers and catch different things.

I wouldn't rely on this alone if I needed per-user or per-API-key rate limits. nginx doesn't know who's authenticated — it sees IPs. For billing-tier enforcement or per-tenant limits, that has to live in the application. But for blunt-force abuse protection, nginx is the right place.

I also wouldn't bother with this for local dev or staging. The config lives in the server block alongside other environment-specific nginx config and doesn't touch the Laravel codebase at all, so there's nothing to accidentally commit.

One more thing: test it

Before you deploy, test that the limits actually fire. A quick way:

for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST https://yourapp.com/login \
    -d 'email=test@example.com&password=wrong';
done

You should see a run of 302 or 422 from Laravel, then 429 once the limit kicks in. If you see all 429 immediately, your burst is too small. If you never see 429, the zone isn't being applied to the location.

It takes about 20 minutes to get this right the first time. After that it's a copy-paste job for every new app. The return on that 20 minutes is not having to explain to a client why their users are getting locked out by a credential stuffing bot at 3am.

Related

Need help shipping something like this? Get in touch.