Detecting Credential Stuffing in Fastify in 30 Lines

An onRequest hook + a preHandler hook that catches per-IP credential stuffing in Fastify. Production-ready in under 30 lines.

May 9, 2026·4 min read

Detecting Credential Stuffing in Fastify in 30 Lines

Fastify hooks make this clean. Block-side in onRequest, record-side in your route handler.

The detector

const FAILURES = new Map();
const BLOCKED = new Map();
const WINDOW_MS = 5 * 60 * 1000;
const THRESHOLD = 10;
const BLOCK_DURATION_MS = 60 * 60 * 1000;

function getIp(req) {
  return req.headers['x-forwarded-for']?.toString().split(',')[0]?.trim() || req.ip;
}

export function recordCredentialFailure(req) {
  const ip = getIp(req);
  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 async function credentialStuffingHook(req, reply) {
  const ip = getIp(req);
  const blockedUntil = BLOCKED.get(ip);
  if (blockedUntil && blockedUntil > Date.now()) {
    return reply.code(429).send({ error: 'Too many failed attempts' });
  }
  if (blockedUntil) BLOCKED.delete(ip);
}

Wiring it up

import Fastify from 'fastify';
import { credentialStuffingHook, recordCredentialFailure } from './credential-stuffing.js';

const app = Fastify({ trustProxy: 1 });

// Run hook only on auth routes
app.register(async (instance) => {
  instance.addHook('onRequest', credentialStuffingHook);

  instance.post('/auth/login', async (req, reply) => {
    const user = await authenticate(req.body.email, req.body.password);
    if (!user) {
      recordCredentialFailure(req);
      return reply.code(401).send({ error: 'Invalid credentials' });
    }
    return { token: createToken(user) };
  });
}, { prefix: '/auth' });

The scoped register runs the hook only for routes under /auth, not for the entire app.

Production: Redis-backed

npm install ioredis
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);

export async function credentialStuffingHook(req, reply) {
  const ip = getIp(req);
  const blocked = await redis.get(`blocked:${ip}`);
  if (blocked) return reply.code(429).send({ error: 'Too many failed attempts' });
}

export async function recordCredentialFailure(req) {
  const ip = getIp(req);
  const now = Date.now();
  await redis.zadd(`failures:${ip}`, now, `${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);
  }
}

What this catches and misses

Same as Express: catches per-IP stuffing, misses distributed attacks.

For distributed detection across services, the SecureNow preload + dashboard correlates auth failures across all your apps automatically:

node -r securenow/register server.js

Related

Frequently Asked Questions

Why split into onRequest and preHandler?

onRequest handles the fast-path block (already-blocked IPs). The actual recording happens after auth fails, which has to be in the route handler or a preHandler with access to the auth result.

Where do I store the failure counts?

In-memory Maps for single-instance deployments, Redis for multi-instance. The structure is the same — a sliding-window count of timestamps per IP.

What about distributed credential stuffing?

Aggregate by ASN as well as by IP. The SecureNow dashboard does this automatically; hand-rolled requires a separate detection rule.

Recommended reading

A Next.js AWS App That Investigates And Blocks Its Own Attack

We deployed a real Next.js app to AWS, connected SecureNow traces, logs, body capture, multipart metadata, and firewall, then used an AI-assisted MCP workflow to block the attacker IP.

May 18
10 Web Development Security Best Practices for Node.js

A prioritized checklist of web development security best practices for Node.js teams, from secure coding to production monitoring and incident response.

May 12
web development security best practices
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