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

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

How to Investigate an Account Takeover by Asking Questions

A step-by-step account takeover investigation in plain English: the ten questions to ask, where each answer lives in your logs, traces and security events, and what to check before you call it closed.

Oct 28
investigate account takeover
Why Per-IP Rate Limits Fail Against Rotating IPs (and What to Count Instead)

One login, 100 rotating IPs: a 10-per-minute-per-IP limit let 275 of 275 requests through. How to spot rotation in your traffic and what to key your limits on.

Oct 27
Rate Limit per API Key, per Session or per IP in Node.js, Without Code Changes

Add rate limits by route and method, counted per IP, per API key, per session cookie or globally, written in plain English and shared across servers with Redis.

Oct 27