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.

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

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

What 1.2B Requests Look Like: Anomaly Patterns from the SecureNow Firewall Fleet

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
10 Best Application Security Monitoring Tools in 2026

An honest, side-by-side comparison of the ten most-deployed application security monitoring tools — from enterprise platforms to free open-source options.

May 9
The 2026 npm Supply-Chain Attack Survey, Q2

A 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