Back to Blogs

Flask Rate Limiting Behind nginx: You're Rate-Limiting the Wrong IP

The Setup That Looks Fine

You install Flask-Limiter, back it with Redis, and add a decorator to your login endpoint:

from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(
    get_remote_address,
    app=app,
    storage_uri="redis://localhost:6379",
    default_limits=["200 per day", "50 per hour"]
)

@app.route("/api/login", methods=["POST"])
@limiter.limit("10 per minute")
def login():
    ...

In development, you hit /api/login eleven times quickly and get a 429. Rate limiting works.

In production, behind nginx, the rate limiter still returns 429 occasionally, but never when you'd expect it to. A brute-force attempt against a real account can run hundreds of requests per minute without ever hitting a limit.

The problem is a single line: get_remote_address.


What request.remote_addr Returns Behind nginx

get_remote_address is Flask-Limiter's default key function. It returns request.remote_addr.

Behind a reverse proxy, request.remote_addr is the proxy's address, not the browser's. nginx sits in front of Flask, forwarding requests. Every request Flask sees arrives from nginx's loopback address (127.0.0.1) or private IP. They all look the same to Flask.

Your "10 per minute per IP" limit becomes "10 per minute total from nginx." Ten requests from ten different users hits the limit. Eleven requests from one script on one machine goes undetected because it arrives from the same 127.0.0.1 as everyone else.

Verify it in thirty seconds:

@app.route("/debug/ip")
def debug_ip():
    return {
        "remote_addr": request.remote_addr,
        "x_forwarded_for": request.headers.get("X-Forwarded-For"),
        "x_real_ip": request.headers.get("X-Real-IP"),
    }

In production, remote_addr will be 127.0.0.1. The real client IP is in the headers nginx sets, assuming nginx is configured to set them.


Fix Part One: Tell Flask About the Proxy

Werkzeug ships with ProxyFix middleware for this:

from werkzeug.middleware.proxy_fix import ProxyFix

app = Flask(__name__)
app.wsgi_app = ProxyFix(
    app.wsgi_app,
    x_for=1,    # trust 1 hop for X-Forwarded-For
    x_proto=1,
    x_host=1,
)

With x_for=1, Werkzeug reads X-Forwarded-For, peels one layer, and rewrites request.remote_addr with the actual client IP. Now get_remote_address sees the right address.

The x_for=1 means trust exactly one proxy in the chain. If you have nginx in front of a load balancer in front of Flask, set x_for=2. Set it to match your actual infrastructure, not to an arbitrarily high number. An attacker can spoof X-Forwarded-For headers, and x_for controls how many trailing hops Werkzeug trusts as legitimate.

nginx also needs to send the header:

location / {
    proxy_pass http://127.0.0.1:5000;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Real-IP       $remote_addr;
    proxy_set_header Host            $host;
}

$proxy_add_x_forwarded_for appends nginx's view of the client IP to any existing X-Forwarded-For chain. $remote_addr sets X-Real-IP to the direct connection address.

With both in place, request.remote_addr in Flask returns the real client IP, and rate limiting per IP works per IP.


Fix Part Two: What Happens When Redis Goes Down

The second failure mode is quieter.

When Flask-Limiter can't reach its storage backend (Redis connection refused, wrong host, expired credentials), the default behavior in most configurations is to let the request through.

This is controlled by the swallow_errors setting:

limiter = Limiter(
    get_remote_address,
    app=app,
    storage_uri="redis://localhost:6379",
    swallow_errors=False,
)

swallow_errors=False means a Redis failure raises an exception rather than silently allowing the request. Your Flask error handler catches it, logs it, and returns a 500. The alert fires. You know Redis is down.

The default is swallow_errors=True, which means the rate limiter stops functioning with no visible error. The app keeps running. Requests keep flowing. You only notice during a post-mortem.

For a login endpoint, I'd rather return 500 and page someone than silently skip rate limiting. A broken rate limiter that appears healthy is worse than no rate limiter, because it creates false confidence.

Check your Limiter initialization and decide which failure mode fits your availability requirements. At minimum, know which one you're getting.


Verifying That It Actually Works

Don't test rate limiting in development against localhost. ProxyFix isn't active, the storage is usually in-memory, and remote_addr is already a real IP. Test against staging.

A bash loop that simulates rapid login attempts:

for i in $(seq 1 15); do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -X POST \
    -H "Content-Type: application/json" \
    -d '{"email":"[email protected]","password":"wrong"}' \
    https://your-staging-host/api/login
done

You should see 401 (or 200) for the first ten, then 429 for the rest. If 429 shows up at request one or two, the bucket is shared; all requests are coming from the same key. If you never see 429, the storage backend is failing open.

Check the response headers. Flask-Limiter adds X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset to responses. If those headers are absent, the limiter isn't running at all. Usually a misconfigured storage URI or an initialization error that got swallowed.

curl -I -X POST https://your-staging-host/api/login
# Look for:
# X-RateLimit-Limit: 10
# X-RateLimit-Remaining: 9
# X-RateLimit-Reset: 1751234567

No headers means no rate limiting.


The nginx Alternative

If you want rate limiting with fewer moving parts, nginx's limit_req_zone handles IP-based limits before requests ever reach Flask:

http {
    limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;
}

server {
    location /api/login {
        limit_req zone=login burst=2 nodelay;
        proxy_pass http://127.0.0.1:5000;
    }
}

nginx sees the real client IP ($binary_remote_addr), the limit runs at the ingress layer, and there's no Redis dependency in Flask. burst=2 nodelay allows a small burst of requests through immediately rather than queuing them.

The tradeoff: nginx knows nothing about your application. It can't limit by the target email address (often more useful than source IP for login endpoints), can't distinguish failed logins from successful ones, and can't return a structured JSON error body without extra configuration.

Flask-Limiter is the right layer when you need application-aware limits: by user ID, by the target resource, or with access to request body data. nginx rate limiting is the right layer for blunt traffic control.

Use both. They don't conflict. nginx limits protect the server from volumetric abuse; Flask limits enforce business logic. The nginx limit fires first, so a misconfigured Flask limiter doesn't expose your app to trivial abuse while you debug it.


The short version: request.remote_addr behind a proxy is the proxy's address. Add ProxyFix with the correct x_for count, configure nginx to send X-Forwarded-For, and confirm with the rate limit response headers that limits are being counted per client.

Then check swallow_errors and decide whether a Redis failure should alert or silently pass traffic. If you're not sure which behavior you're getting, curl the staging endpoint eleven times and look at what the headers say.

Enjoyed this?

Share it with your network