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.

May 9, 2026·5 min read

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.

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

How to Investigate an Account Takeover by Asking Questions

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
investigate account takeover
Why Per-IP Rate Limits Fail Against Rotating IPs (and What to Count Instead)

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 27
Rate Limit per API Key, per Session or per IP in Node.js, Without Code Changes

Add 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