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.

Lhoussine
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

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