← Back to Articles DevOps & CI/CD

Leaky Buckets at the Edge: Rate Limiting Login and Contact Endpoints in Nginx

Oselio Candido · Jul 2026 · 6 min read

The problem

An application server sitting directly on the internet has to spend its own CPU cycles deciding whether every request is legitimate. A login endpoint with no rate limiting will happily accept a credential-stuffing script's ten thousand attempts a minute, burning database connections and process time on traffic that should never have reached the app in the first place.

A public contact form with no throttling has the same problem in a different shape: it becomes a free spam relay, and every submission still walks the full request pipeline — validation, mail dispatch, logging — before anyone decides it was junk.

Neither of these is an application-logic problem. They're edge problems, and solving them inside the app means paying their cost in the app's own resources instead of rejecting the traffic before it arrives.

What is a leaky bucket rate limiter

Nginx's limit_req module implements the leaky bucket algorithm. Picture a bucket with a hole: requests pour in at whatever rate the client sends them, and they leak out — get processed — at a fixed rate you configure. If requests arrive faster than the hole drains, the bucket fills; when it overflows, requests are rejected.

Two concepts do all the work:

  • The zone — shared memory where nginx tracks per-key state. Keying on $binary_remote_addr means one bucket per client IP; a 10 MB zone holds state for roughly 160,000 addresses.
  • Burst + nodelay — real traffic is spiky. burst is the bucket's depth: how many requests over the steady rate are tolerated. nodelay says "serve burst requests immediately instead of queuing them" — excess beyond the burst is rejected with an error, not delayed.

For comparison, a token bucket — the other classic algorithm, used by many API gateways — accumulates permission over idle time, allowing genuine bursts at full speed. Nginx's leaky bucket with nodelay behaves almost identically in practice, which matters for what comes next: it meant reaching for the module nginx already ships with, instead of a heavier piece of middleware.

The alternatives

Rate limiting could have been implemented inside the application instead of at the edge, and each option carried real trade-offs.

  • Application-level rate limiting (a middleware decorator counting requests per IP or per user in the app process, backed by an in-memory dict or Redis) — gives finer control, since the app knows who's logged in, not just which IP is asking. But every rejected request still costs a full trip through the app's request pipeline before it can be turned away, and the floods that matter most are exactly the ones you want rejected before they ever reach application code.
  • A dedicated API gateway or WAF (Kong, Cloudflare, AWS API Gateway) — offers richer rules, geo-based limits, bot scoring. For a single-box deployment this is real infrastructure and cost for a problem two lines of nginx config solve just as well at the traffic volumes involved.
  • No rate limiting, rely on infrastructure-level DDoS protection — covers volumetric floods but does nothing for a low-and-slow credential-stuffing attempt that looks like ordinary traffic to a network-layer filter.

Given a single-box deployment already running nginx as a reverse proxy in front of the app, the edge was already there — the question was whether to use it or bypass it.

The decision

Not every endpoint needs limiting — the dangerous ones are authentication (credential stuffing) and the public contact form (spam). This is the actual config, from the host nginx file in front of the FastAPI backend on 127.0.0.1:8001. Each endpoint got its own zone, sized to how abusive traffic against it actually looks:

limit_req_zone $binary_remote_addr
    zone=login_limit_beta:10m   rate=5r/m;

limit_req_zone $binary_remote_addr
    zone=contact_limit_beta:10m rate=3r/m;

And each zone is applied at its location, with a burst allowance and an honest error contract:

location /api/auth/ {
    limit_req zone=login_limit_beta burst=3 nodelay;
    limit_req_status 429;
    add_header Retry-After 60 always;

    proxy_pass http://fastapi_backend_beta/api/auth/;
    proxy_connect_timeout 5s;
    proxy_send_timeout 5s;
    proxy_read_timeout 5s;
}

location = /api/contact {
    limit_req zone=contact_limit_beta burst=5 nodelay;
    limit_req_status 429;
    add_header Retry-After 60 always;

    proxy_pass http://fastapi_backend_beta/api/contact;
    proxy_connect_timeout 10s;
    proxy_send_timeout 10s;
    proxy_read_timeout 10s;
}

Reading the login numbers: a client may make 5 auth requests per minute sustained, with 3 extra tolerated in a spike — plenty for a human who mistypes a password twice and then finds their TOTP code, hopeless for a script trying a password list. Rejected requests get 429 Too Many Requests plus a Retry-After: 60 header, so well-behaved clients know exactly what to do, and the app process never spends a single CPU cycle on the flood.

The two locations also carry different proxy timeouts — 5 seconds on /api/auth/, 10 on /api/contact — which isn't part of the rate-limiting logic itself, but reflects the same instinct: an auth check should be fast and fail fast, while a contact-form submission (which may trigger mail dispatch downstream) is given more room before nginx gives up on the upstream.

Per-IP, not per-account

At the edge, the IP is all nginx knows — it can't see who the login is for. Per-IP limiting is the coarse outer wall; finer per-account protections, like MFA (covered in its own write-up), belong in the application, where identity actually exists.

The edge handles what it can see cheaply; the app handles what requires context — that division of labor, not any single directive, is the actual design decision here.

Per-IP limiting has a known blind spot worth naming rather than glossing over: corporate NATs put many humans behind one IP, so they share a bucket, and a botnet spreads one attacker across many IPs, so no single bucket ever fills. The mitigation doesn't eliminate credential stuffing — it raises its cost enough that unsophisticated scripts fail outright and sophisticated ones have to work much harder, while human users are barely affected.

The outcome

Authentication and contact-form abuse now gets rejected before it ever touches application code: a flood past the configured rate returns a 429 with a clear Retry-After hint, at the cost of a shared-memory lookup instead of a full request-handling cycle. The same zone definitions carry across environments — production, staging, and a demo tier — differing only in the upstream port and zone name, so the same protection applies everywhere the app is reachable, at effectively zero ongoing cost beyond the initial configuration.