Detecting Credential Stuffing in Express in 30 Lines
A working credential-stuffing detector for Express in 30 lines of middleware. Counts auth failures per IP in a sliding window, blocks offenders, exposes a small management API.
Detecting Credential Stuffing in Express in 30 Lines
If your login endpoint isn't protected against credential stuffing, the next attack is a matter of when, not if. The detection is genuinely simple: count auth failures per IP in a sliding window, block offenders. Here's the working code.
For broader context on credential stuffing detection through trace data, see the APM credential-stuffing post.
The 30 lines
const FAILURES = new Map(); // ip -> array of failure timestamps
const WINDOW_MS = 5 * 60 * 1000; // 5 minute window
const THRESHOLD = 10; // 10 failures = block
const BLOCK_DURATION_MS = 60 * 60 * 1000; // 1 hour block
const blocked = new Map(); // ip -> blocked-until timestamp
function getIp(req) {
return req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress;
}
function recordFailure(ip) {
const now = Date.now();
const recent = (FAILURES.get(ip) || []).filter(t => now - t < WINDOW_MS);
recent.push(now);
FAILURES.set(ip, recent);
if (recent.length >= THRESHOLD) blocked.set(ip, now + BLOCK_DURATION_MS);
}
export function credentialStuffingGuard(req, res, next) {
const ip = getIp(req);
const blockedUntil = blocked.get(ip);
if (blockedUntil && blockedUntil > Date.now()) {
return res.status(429).json({ error: 'Too many failed attempts. Try again later.' });
}
if (blockedUntil) blocked.delete(ip);
next();
}
export function recordAuthFailure(req) {
recordFailure(getIp(req));
}
Hooking it up
In your Express app:
import { credentialStuffingGuard, recordAuthFailure } from './credential-stuffing-guard.js';
app.post('/auth/login', credentialStuffingGuard, async (req, res) => {
const user = await authenticate(req.body.email, req.body.password);
if (!user) {
recordAuthFailure(req);
return res.status(401).json({ error: 'Invalid credentials' });
}
// ... rest of login
});
That's the whole thing. 10 failed logins from one IP in 5 minutes triggers a 1-hour block. The block returns 429 (Too Many Requests) instead of 401, which is the correct semantic — they're not unauthorized, they're rate-limited.
What it catches and what it misses
Catches: classic single-IP credential stuffing where an attacker hammers /auth/login from one source. This is still 40–60% of credential-stuffing attempts in 2026.
Misses:
- Distributed attacks using residential proxies (each IP stays under threshold)
- Slow-rolling attacks (one attempt every 10 minutes per IP)
- Attacks from very large IP pools that complete before the window resets
For those, you need cross-IP aggregation (ASN-level, fingerprint-level) and behavioral baselines. That's a bigger build — usually you'd reach for a dedicated tool at this point.
Adding ASN-level detection
The next layer up. Same Map, different key:
import lookup from 'ip-asn-lookup'; // or your preferred lib
const ASN_FAILURES = new Map();
function recordAsnFailure(ip) {
const asn = lookup(ip)?.asn;
if (!asn) return;
const now = Date.now();
const recent = (ASN_FAILURES.get(asn) || []).filter(t => now - t < WINDOW_MS);
recent.push(now);
ASN_FAILURES.set(asn, recent);
if (recent.length >= THRESHOLD * 5) {
// 5x threshold across an ASN → block the whole ASN temporarily
blockedAsns.add(asn);
}
}
This catches attacks from any cloud provider or hosting ASN — common for distributed attacks because attackers rent compute. Be careful with consumer ASNs (cable, mobile) — blocking them blocks legitimate users.
Production hardening
Three things to add for production:
1. Persist across restarts. The in-memory FAILURES Map resets on deploy. Use Redis or another shared store:
import { createClient } from 'redis';
const redis = createClient();
async function recordFailure(ip) {
const now = Date.now();
await redis.zAdd(`failures:${ip}`, { score: now, value: `${now}` });
await redis.zRemRangeByScore(`failures:${ip}`, 0, now - WINDOW_MS);
const count = await redis.zCard(`failures:${ip}`);
if (count >= THRESHOLD) await redis.set(`blocked:${ip}`, '1', { EX: BLOCK_DURATION_MS / 1000 });
}
2. Alert on blocks. When a new IP gets blocked, post to Slack or your incident channel. Useful both for awareness and for tuning thresholds.
3. Allowlist trusted IPs. Internal IPs, your own QA team, known partners — don't let them get blocked by accident.
When to use a dedicated tool instead
Hand-rolled detection is fine when:
- You have a single Express service
- Auth is a small fraction of your overall traffic
- You're early-stage and willing to iterate
Reach for a dedicated tool when:
- You have multiple services that all need the same protection
- You want correlation across services (one IP attacking auth on service A and signup on service B is the same attack)
- You need investigation depth — "who hit this account?" should be a 10-second answer, not a grep through logs
The SecureNow firewall provides this at the npm-package level — preload securenow/register and you get the same detection logic, plus IP reputation, plus AI investigation, with no per-service configuration.
Verifying it works
Run a synthetic test:
for i in {1..15}; do
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"wrong"}'
done
The first 10 should return 401. The 11th should return 429. After 1 hour, you can try again.
Related
Frequently Asked Questions
Is 30 lines really enough for credential-stuffing detection?
For per-IP attacks, yes. For distributed attacks (rotating proxies, slow rolling), you need rules that aggregate at the ASN level or use behavioral fingerprinting — those need more code or a dedicated tool.
What threshold should I use?
Start at 10 failures in 5 minutes per IP for a typical web app. Tune from there based on your normal traffic. Production-aware tools use adaptive thresholds based on historical baselines.
Should I use Redis instead of in-memory state?
Yes if you have multiple Express instances behind a load balancer. The in-memory version below works for single-instance deployments; for clusters, swap the Map for Redis or another shared store.
How does this compare to a CAPTCHA?
Complementary. Detection-and-block stops obvious attacks at the network edge; CAPTCHA challenges marginal cases. Best practice is to detect aggressively and challenge-not-block on borderline IPs (return 200 with CAPTCHA HTML instead of 401).
Recommended reading
Aggregated, anonymized data from 1.2B requests across the SecureNow customer fleet. Top anomaly types, peak hours, and the day-of-week patterns nobody publishes.
May 9An honest, side-by-side comparison of the ten most-deployed application security monitoring tools — from enterprise platforms to free open-source options.
May 9A quarterly tally of malicious npm packages, the major incidents, and detection patterns. April 2026 set a new record at 847 confirmed malicious packages — here's what they did and how to detect them.
May 9