security beginner

Rate Limiting with Flask-Limiter

min read Frederick Tubiermont

Rate Limiting with Flask-Limiter

Without rate limiting, anyone can hammer your login route 10,000 times per second to brute-force passwords, or scrape your entire database through your API. Rate limiting prevents this.

Installation

pip install Flask-Limiter
Flask-Limiter==3.8.0

Basic Setup

# app.py
from flask import Flask
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
import os

app = Flask(__name__)

limiter = Limiter(
    app=app,
    key_func=get_remote_address,  # Limit per IP address
    default_limits=["200 per day", "50 per hour"],  # Global defaults
    storage_uri=os.environ.get("REDIS_URL", "memory://")
)

storage_uri="memory://" works for single-process development. In production with multiple workers, use Redis.

Applying Limits to Routes

# Use the global default limits
@app.route("/")
def home():
    return render_template("home.html")


# Override with stricter limits (brute force protection)
@app.route("/login", methods=["POST"])
@limiter.limit("5 per minute")
def login():
    # Only 5 login attempts per minute per IP
    pass


# Multiple limits on one route
@app.route("/api/data")
@limiter.limit("100 per hour")
@limiter.limit("10 per minute")
def api_data():
    pass


# Exempt a route from all limits (public CDN endpoint, health check)
@app.route("/health")
@limiter.exempt
def health():
    return "ok"

Limit Strings

Flask-Limiter uses a human-readable format:

"5 per minute"
"100 per hour"
"1000 per day"
"3 per second"
"10 per 15 minutes"

Custom Error Response

When a limit is hit, Flask-Limiter returns a 429 by default. Customize it:

from flask import jsonify, render_template
from flask_limiter.errors import RateLimitExceeded

@app.errorhandler(429)
def rate_limit_handler(e):
    # For API requests, return JSON
    if request.is_json or request.path.startswith("/api/"):
        return jsonify({
            "error": "Rate limit exceeded",
            "message": str(e.description),
            "retry_after": e.retry_after
        }), 429

    # For browser requests, render a page
    return render_template("429.html", retry_after=e.retry_after), 429

Limiting Per User (Not Just IP)

IP-based limits break when many users share an IP (offices, universities). Limit by user ID for authenticated routes:

def get_user_key():
    """Use user ID if logged in, otherwise fall back to IP."""
    from flask import session
    if "user_id" in session:
        return f"user:{session['user_id']}"
    return get_remote_address()

# Apply per-user limit to specific routes
@app.route("/api/export")
@login_required
@limiter.limit("3 per hour", key_func=get_user_key)
def export_data():
    pass

Production Setup with Redis

In production, multiple gunicorn workers don't share memory. Use Redis so all workers share the same counter:

limiter = Limiter(
    app=app,
    key_func=get_remote_address,
    default_limits=["200 per day", "50 per hour"],
    storage_uri=os.environ.get("REDIS_URL", "redis://localhost:6379/0")
)

Add to .env:

REDIS_URL=redis://localhost:6379/0

Common Limit Configurations

# Login — strict anti-brute-force
@app.route("/login", methods=["POST"])
@limiter.limit("5 per minute; 20 per hour")
def login():
    pass

# Registration — prevent spam signups
@app.route("/register", methods=["POST"])
@limiter.limit("3 per hour")
def register():
    pass

# Password reset — prevent email flooding
@app.route("/forgot-password", methods=["POST"])
@limiter.limit("3 per hour")
def forgot_password():
    pass

# Public API — reasonable usage
@app.route("/api/search")
@limiter.limit("30 per minute")
def api_search():
    pass

# Admin actions — low limit, high severity
@app.route("/admin/delete-user/<int:id>", methods=["POST"])
@admin_required
@limiter.limit("10 per hour", key_func=get_user_key)
def delete_user(id):
    pass

Showing Limit Headers to API Clients

Flask-Limiter automatically adds rate limit headers to responses:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1735689600

API clients can use these to know when to back off.

Was this helpful?

Get More Flask Vibe Tutorials

Join 1,000+ developers getting weekly Flask tips and AI-friendly code patterns.

No spam. Unsubscribe anytime.