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.

Lhoussine
May 9, 2026·6 min read

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

What 1.2B Requests Look Like: Anomaly Patterns from the SecureNow Firewall Fleet

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 9
10 Best Application Security Monitoring Tools in 2026

An honest, side-by-side comparison of the ten most-deployed application security monitoring tools — from enterprise platforms to free open-source options.

May 9
The 2026 npm Supply-Chain Attack Survey, Q2

A 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