How to Prevent Login Brute-Force With Rate Limiting
A login endpoint with no attempt tracking lets an attacker try millions of passwords per minute. Adding per-IP rate limiting - block after 5 failures in 60 seconds, return 429 - stops brute-force with roughly 20 lines of Python.
The threat: unlimited password guesses
Without rate limiting, an attacker can POST to /login as fast as the server will
accept connections. A four-digit PIN has 10,000 combinations; at 1,000 requests per
second that's cracked in 10 seconds. The fix is to count failed attempts and return
429 Too Many Requests once the threshold is crossed.
Tracking attempts in memory
Use a defaultdict(list) that maps each client IP to a list of timestamps for
recent failures. Before processing a login, prune expired entries and check the
count:
import time
from collections import defaultdict
from flask import Flask, request, jsonify
app = Flask(__name__)
_attempts = defaultdict(list)
MAX_ATTEMPTS = 5
WINDOW = 60 # seconds
@app.post("/login")
def login():
ip = request.remote_addr or "unknown"
now = time.time()
# prune timestamps outside the window
_attempts[ip] = [t for t in _attempts[ip] if now - t < WINDOW]
if len(_attempts[ip]) >= MAX_ATTEMPTS:
return jsonify({"error": "Too many attempts"}), 429
data = request.get_json()
username = data.get("username", "")
password = data.get("password", "")
user = check_credentials(username, password)
if user:
_attempts.pop(ip, None) # clear on success
return jsonify({"message": "Login successful"}), 200
else:
_attempts[ip].append(now) # record only on failure
return jsonify({"error": "Invalid credentials"}), 401
Key design decisions
- Prune before counting - remove timestamps older than
WINDOWseconds before comparing toMAX_ATTEMPTS. This gives a true sliding window, not a fixed bucket. - Record only on failure - append to
_attempts[ip]only when credentials are wrong, not on every request. Counting all requests would lock out valid users on slow networks. - Clear on success -
_attempts.pop(ip, None)lets a user who remembers their password log in even after hitting the limit, which is the correct UX. - Return 429, not 401 - using the correct status code lets clients and WAFs distinguish a rate-limit response from a wrong-password response.
What a blocked response looks like
# after 5 wrong attempts:
curl -s -o /dev/null -w "%{http_code}" \
-X POST http://localhost:8000/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "wrong"}'
# -> 429
Production upgrades
In-memory tracking works per-process but resets on restart and does not span multiple instances. For production:
- Redis - store
INCR login:fail:{ip}withEXPIRE 60; survives restarts and works across a load-balanced fleet. - Per-username tracking - an attacker who rotates IPs bypasses per-IP limits; also count failures per username.
- CAPTCHA fallback - offer a CAPTCHA challenge before the hard block so legitimate users are not locked out entirely.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Implementing a sliding-window attempt tracker with defaultdict and time.time()
- Returning 429 before processing credentials and clearing state on successful login
- Testing that the rate limit fires after 5 failures and that valid credentials still work
FAQ
How do I add brute-force protection to a Flask login endpoint?
Track failed login attempts per IP in a dict mapping the IP to a list of failure timestamps. Before each attempt, drop entries older than your window (e.g. 60s) and return 429 if the count hits your limit (e.g. 5). Clear the entry on a successful login.
What HTTP status code should a rate-limited login return?
Return 429 Too Many Requests. It is the standard code for rate limiting and lets clients, WAFs, and monitoring tools distinguish a blocked request from a wrong-password 401.
Why doesn't in-memory rate limiting work in production?
In-memory state lives in a single process - it resets on restart and is invisible to other instances behind a load balancer. Production apps use Redis (INCR + EXPIRE) so limits persist and span every server.
How do you prevent brute-force login attacks?
Layer a few defenses: rate-limit attempts per IP and per account (e.g. block after 5 failures in 60s, return 429), add exponential back-off or a temporary lockout, require CAPTCHA after repeated failures, enforce strong passwords, and enable multi-factor authentication. Rate limiting is the first and highest-impact layer.
How many failed login attempts should trigger a lockout?
A common policy is 5 failed attempts within a short window (30-60 seconds), then a temporary block rather than a permanent lock - permanent locks let an attacker lock out real users by guessing their email. Tune the threshold to your traffic.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →