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.
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
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