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
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 18A prioritized checklist of web development security best practices for Node.js teams, from secure coding to production monitoring and incident response.
May 12
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