Detecting Credential Stuffing in NestJS in 30 Lines
A working credential-stuffing guard for NestJS using a Guard + ThrottlerModule. Production-ready in 30 lines plus the module config.
Detecting Credential Stuffing in NestJS in 30 Lines
NestJS has good primitives for this — Guards run before the route handler, decorators bind to specific endpoints. Here's the credential-stuffing guard.
The Guard
// src/auth/credential-stuffing.guard.ts
import { CanActivate, ExecutionContext, HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { Request } from 'express';
const FAILURES = new Map<string, number[]>();
const BLOCKED = new Map<string, number>();
const WINDOW_MS = 5 * 60 * 1000;
const THRESHOLD = 10;
const BLOCK_DURATION_MS = 60 * 60 * 1000;
@Injectable()
export class CredentialStuffingGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>();
const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.socket.remoteAddress || '';
const blockedUntil = BLOCKED.get(ip);
if (blockedUntil && blockedUntil > Date.now()) {
throw new HttpException('Too many failed attempts. Try again later.', HttpStatus.TOO_MANY_REQUESTS);
}
if (blockedUntil) BLOCKED.delete(ip);
return true;
}
}
export function recordCredentialFailure(ip: string) {
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);
}
Wiring it up
// src/auth/auth.controller.ts
import { Body, Controller, Post, UseGuards, Req } from '@nestjs/common';
import { Request } from 'express';
import { CredentialStuffingGuard, recordCredentialFailure } from './credential-stuffing.guard';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
constructor(private auth: AuthService) {}
@Post('login')
@UseGuards(CredentialStuffingGuard)
async login(@Body() body: any, @Req() req: Request) {
const user = await this.auth.validate(body.email, body.password);
if (!user) {
const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.socket.remoteAddress || '';
recordCredentialFailure(ip);
throw new HttpException('Invalid credentials', HttpStatus.UNAUTHORIZED);
}
return { token: this.auth.createToken(user) };
}
}
Production: shared store
For multi-instance deployments, swap the in-memory Maps for Redis. Inject Redis via NestJS's standard DI:
// src/auth/credential-stuffing.guard.ts
import { Injectable, Inject } from '@nestjs/common';
import Redis from 'ioredis';
@Injectable()
export class CredentialStuffingGuard implements CanActivate {
constructor(@Inject('REDIS') private redis: Redis) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request>();
const ip = /* extract IP */;
const blocked = await this.redis.get(`blocked:${ip}`);
if (blocked) {
throw new HttpException('Too many failed attempts', HttpStatus.TOO_MANY_REQUESTS);
}
return true;
}
}
recordCredentialFailure becomes async with Redis sorted-set commands (zadd, zremrangebyscore, zcard).
Combining with @nestjs/throttler
For general rate limiting, run @nestjs/throttler alongside:
// app.module.ts
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
@Module({
imports: [
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
],
})
export class AppModule {}
Two layers: Throttler for raw RPS limits, CredentialStuffingGuard for auth-failure counting.
What this catches
- Per-IP credential stuffing
- Brute-force attacks against a single account from one source
What it misses
- Distributed attacks across many IPs
- Slow attacks under threshold
For those, use the SecureNow preload, which adds AI investigation to detect cross-IP coordinated attacks:
node -r securenow/register dist/main.js
The dashboard then alerts you to coordinated attempts the per-IP guard would miss.
Related
Frequently Asked Questions
Should I use ThrottlerModule or a custom Guard?
ThrottlerModule for general rate limiting; a custom Guard for credential-stuffing specifically because you want to count failed attempts only, not all attempts.
Where does the failure count get stored?
In production, in Redis (shared across instances). For development or single-instance deployments, in-memory is fine. NestJS doesn't dictate the store; pick what fits your infrastructure.
Can I combine this with NestJS Throttler?
Yes — they protect different things. Throttler blocks based on raw request rate; the credential-stuffing guard blocks based on auth failures specifically. Run both.
What about the SecureNow auto-detection?
If you use `securenow/register` as a preload, the dashboard auto-detects credential stuffing across all your services. The hand-rolled version below gives you in-app control; the SecureNow version gives you cross-service visibility.
Recommended reading
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
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 27Add 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