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