Credential stuffing is one of the most common attacks hitting web applications today. Attackers take breached username/password pairs — billions are available — and replay them against your login endpoints at scale. Individual requests look legitimate, so standard WAF rules won't catch them. The signal is in the volume and pattern. Your nginx access log already has everything you need.
What Credential Stuffing Looks Like in Practice
A typical login endpoint sees 50–200 POST requests per hour from organic traffic. During a credential stuffing attack, that number spikes — often to thousands per hour — from a distributed set of IPs, with a tell-tale mix of 401/403 failures and occasional 200/302 successes.
The key indicators in your nginx log:
- High POST volume to
/login,/api/auth,/signin, or similar endpoints - Many distinct IPs hitting the same endpoint in a short window
- Abnormally high failure ratio — or inversely, a suspicious success rate from a single IP
- Compressed User-Agent diversity: bots often rotate through a small set of UAs
- Geographic spread inconsistent with your user base
Your nginx log is already recording all of this. You just need to parse it.
Parsing Nginx Access Logs with Python
The default nginx combined log format looks like this:
127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "POST /login HTTP/1.1" 401 612 "-" "Mozilla/5.0"
Here is a parser that extracts the fields you need:
import re
from datetime import datetime
from dataclasses import dataclass
from typing import Optional
LOG_PATTERN = re.compile(
r'(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] '
r'"(?P<method>\S+) (?P<path>\S+) \S+" '
r'(?P<status>\d{3}) \d+ "[^"]*" "(?P<ua>[^"]*)"'
)
@dataclass
class LogEntry:
ip: str
time: datetime
method: str
path: str
status: int
user_agent: str
def parse_log_line(line: str) -> Optional[LogEntry]:
m = LOG_PATTERN.match(line.strip())
if not m:
return None
return LogEntry(
ip=m.group("ip"),
time=datetime.strptime(m.group("time"), "%d/%b/%Y:%H:%M:%S %z"),
method=m.group("method"),
path=m.group("path"),
status=int(m.group("status")),
user_agent=m.group("ua"),
)
def load_log(path: str) -> list[LogEntry]:
entries = []
with open(path) as f:
for line in f:
entry = parse_log_line(line)
if entry:
entries.append(entry)
return entries
This gives you a clean list of structured entries. From here, detection is counting with a time window.
Detection Logic: Spotting the Attack Window
Credential stuffing attacks have a temporal signature. You do not want to look at totals across an entire log file — you want a sliding window. Fixed 10-minute buckets are simple and usually sufficient:
from collections import defaultdict
import json
AUTH_PATHS = {"/login", "/api/auth", "/signin", "/account/login", "/wp-login.php"}
WINDOW_MINUTES = 10
RATE_THRESHOLD = 200 # POST requests per IP per window
UNIQUE_IP_THRESHOLD = 50 # distinct IPs hitting auth in one window
def bucket_key(entry) -> str:
t = entry.time
bucket = t.replace(
minute=(t.minute // WINDOW_MINUTES) * WINDOW_MINUTES,
second=0,
microsecond=0,
)
return bucket.isoformat()
def detect_credential_stuffing(entries: list) -> list[dict]:
windows = defaultdict(lambda: defaultdict(list))
for entry in entries:
if entry.method == "POST" and entry.path in AUTH_PATHS:
windows[bucket_key(entry)][entry.ip].append(entry)
alerts = []
for bucket, ip_map in windows.items():
total_requests = sum(len(v) for v in ip_map.values())
unique_ips = len(ip_map)
if unique_ips >= UNIQUE_IP_THRESHOLD:
alerts.append({
"window": bucket,
"type": "distributed_stuffing",
"unique_ips": unique_ips,
"total_requests": total_requests,
})
for ip, reqs in ip_map.items():
if len(reqs) >= RATE_THRESHOLD:
failures = sum(1 for r in reqs if r.status >= 400)
ratio = failures / len(reqs)
alerts.append({
"window": bucket,
"type": "single_ip_stuffing",
"ip": ip,
"requests": len(reqs),
"failure_ratio": round(ratio, 2),
})
return alerts
if __name__ == "__main__":
entries = load_log("/var/log/nginx/access.log")
for alert in detect_credential_stuffing(entries):
print(json.dumps(alert))
Run this against your log and you get JSON lines for each suspicious window. Pipe to a file, post to a webhook, or push into your SIEM.
Calibrating Thresholds Against Your Baseline
The numbers above are starting points, not gospel. Before running this in production, establish your baseline:
grep "POST /login" /var/log/nginx/access.log | \
awk '{print $4}' | \
cut -d: -f2-3 | \
sort | uniq -c | sort -rn | head -20
This shows your top 20 busiest 10-minute windows for login POSTs. If your highest organic bucket is 80 requests from 25 IPs, set RATE_THRESHOLD=250 and UNIQUE_IP_THRESHOLD=70 to leave headroom without being too loose.
A few failure modes to handle in production:
CDN and reverse proxy IPs: If traffic passes through Cloudflare or a load balancer, $remote_addr is the proxy's IP. Update your nginx log format to include $http_x_forwarded_for and adjust the parser accordingly.
Mobile carrier NAT: Some mobile networks share egress IPs. A spike in unique IPs from the same /20 subnet is usually NAT, not an attack. Add an ASN lookup step (e.g., via the ipwhois library) to distinguish.
Scraper bots on the login page: GET requests to the login form will not trigger the POST filter. The detection code above already handles this by filtering on entry.method == "POST".
For a complete hardening baseline — what to log, how to structure alerts, and when to block versus challenge — the security hardening checklists at AYI NEDJIMI Consultants are a useful free reference.
Taking Action on Alerts
Detecting is step one. A few response patterns that work in practice:
1. fail2ban integration. Write a custom jail that reads your JSON alert output and bans offending IPs via iptables. This handles single-IP attacks automatically and is straightforward to configure.
2. Nginx global rate limiting. For distributed attacks with many IPs and no single dominant source, per-IP banning is ineffective. Use nginx's limit_req_zone to throttle your auth endpoint globally, and consider requiring a challenge after N failures per session cookie.
3. Monitor success rates, not just volume. The most critical signal is a spike in 200/302 responses from your auth endpoint correlated with a volume spike. Volume anomalies mean someone is trying. Success anomalies mean someone is succeeding — that is an incident, not just a rate-limit trigger.
4. Alert and investigate. A distributed credential stuffing campaign is usually downstream of a breach elsewhere. Worth checking whether your users' credentials appear in recent breach data and proactively resetting suspicious sessions.
The Takeaway
Your nginx logs are a real-time source of attack telemetry, and credential stuffing has a clear fingerprint: high POST volume to auth endpoints, unusual IP diversity, and a skewed failure rate. The Python code in this post adds up to around 100 lines with no external dependencies.
In production, wrap this in a systemd timer running every 10 minutes against your rotated logs, or adapt it into a Kafka consumer for high-volume deployments. The detection logic stays the same — you are just adjusting the data source and the response mechanism.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)