Building an API that works is table stakes. Building one that is secure under real-world attack conditions is where most production systems fall short. I run NestJS APIs serving ERP data for business clients — financial records, employee data, inventory levels. An API security failure on these systems has direct business consequences. After hardening multiple NestJS APIs and dealing with real attack attempts (brute force, injection probes, enumeration attacks), I have a security configuration checklist I apply to every deployment. This post covers the specific controls, code patterns, and monitoring that I use in production.
The first security control in any NestJS API is input validation using class-validator and class-transformer. Every DTO (Data Transfer Object) should have explicit validation decorators — @IsString(), @IsEmail(), @IsInt({ min: 0 }), @MaxLength(255), etc. Enable the global ValidationPipe with whitelist: true (strips unknown properties) and forbidNonWhitelisted: true (throws on unknown properties rather than silently ignoring them). This prevents mass assignment attacks and ensures only expected properties reach your service layer.
Configure the global ValidationPipe in main.ts with strict settings. Add Helmet for security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, etc.) via the @nestjs/helmet package. Add compression middleware. Set a request body size limit to prevent large payload DoS attacks. These four lines of configuration — ValidationPipe, Helmet, compression, body limit — cover a significant surface area of common API vulnerabilities with minimal code.
// main.ts — NestJS production security configuration
import { NestFactory } from '@nestjs/core'
import { ValidationPipe } from '@nestjs/common'
import helmet from 'helmet'
import * as compression from 'compression'
import { AppModule } from './app.module'
import { HttpExceptionFilter } from './filters/http-exception.filter'
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn'], // no verbose in production
})
// 1. Security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
},
},
hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
}))
// 2. Input validation (global)
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // strip unknown properties
forbidNonWhitelisted: true, // throw on unknown properties
transform: true, // auto-transform to DTO types
disableErrorMessages: false, // keep validation messages
exceptionFactory: (errors) => {
// Return uniform error format
return new BadRequestException({
statusCode: 400,
error: 'Validation Error',
details: errors.map(e => ({
field: e.property,
constraints: Object.values(e.constraints ?? {}),
})),
})
},
}))
// 3. Body size limit (prevent large payload DoS)
app.use(compression())
// In Express underlying: app.use(express.json({ limit: '1mb' }))
// 4. Global exception filter (no stack traces in responses)
app.useGlobalFilters(new HttpExceptionFilter())
// 5. CORS (allowlist only)
const origins = process.env.ALLOWED_ORIGINS?.split(',') ?? []
app.enableCors({
origin: (origin, cb) => (!origin || origins.includes(origin)) ? cb(null, true) : cb(new Error('Not allowed')),
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
})
await app.listen(3000)
}
bootstrap()From my experience securing ERP APIs: add a custom ExceptionFilter that normalizes error responses. By default, NestJS returns different error response shapes for validation errors, HTTP exceptions, and unhandled exceptions. A uniform error response format prevents information leakage (stack traces, internal paths, database error messages) and makes security scanning harder. Never return raw database error messages — they reveal schema information and query structure.
Every public API endpoint needs rate limiting. NestJS has the @nestjs/throttler package which integrates cleanly. Configure three tiers: global limits (100 requests per minute per IP), auth endpoints (10 requests per minute per IP — much stricter), and authenticated endpoints (500 requests per minute per user). For auth endpoint rate limiting, also implement account-level lockout (not just IP-based) because attackers route through multiple IPs. Store rate limit counters in Redis for horizontal scaling — the in-memory default does not work with multiple API instances.
At the infrastructure level, integrate with fail2ban to block persistent attackers at the nginx layer before they reach your NestJS application. Log structured JSON from your API (timestamp, IP, endpoint, status code, user_id) and write a fail2ban filter that triggers on patterns like: 20+ auth failures from one IP in 5 minutes, 50+ 422 validation errors from one IP in 10 minutes, or any SQLmap signature patterns in the request. Block for 24 hours on first trigger, 7 days on second, permanent on third.
// throttler.module.ts — rate limiting with Redis storage
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'
import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis'
import { APP_GUARD } from '@nestjs/core'
@Module({
imports: [
ThrottlerModule.forRootAsync({
inject: [RedisService],
useFactory: (redis: RedisService) => ({
throttlers: [
{ name: 'global', ttl: 60000, limit: 100 }, // 100/min global
{ name: 'auth', ttl: 60000, limit: 10 }, // 10/min for auth
{ name: 'user', ttl: 60000, limit: 500 }, // 500/min per user
],
storage: new ThrottlerStorageRedisService(redis.client),
}),
}),
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class ThrottlerConfigModule {}
// Apply stricter limit on auth endpoints
@Controller('auth')
@Throttle({ auth: { ttl: 60000, limit: 5 } }) // 5 attempts/min
export class AuthController {
@Post('login')
@UseGuards(ThrottleByIpGuard) // IP-based, not user-based
async login(@Body() dto: LoginDto, @Req() req: Request) {
// Track failed attempts for account lockout
const attempts = await this.redisService.incr(`login_attempts:${dto.email}`)
if (attempts > 10) throw new TooManyRequestsException('Account locked')
// ...
}
}
// fail2ban filter for nginx logs
// /etc/fail2ban/filter.d/nestjs-api.conf:
// [Definition]
// failregex = ^<HOST> .* "POST /auth/login HTTP.*" 401
// ^<HOST> .* ".*" 422 # validation failures
// ignoreregex =NestJS guards are the right place to implement authentication. A JWT guard validates the token on every request and attaches the user to the request context. Then use custom decorators for authorization: @Roles('admin') checked by a RolesGuard, @CheckOwnership() verified by an ownership guard that queries the resource. The critical rule: authentication (who are you?) and authorization (what can you do?) are separate concerns handled by separate guards. Do not mix them in service methods — keep authorization at the controller/guard layer where it is systematic and auditable.
I reviewed a production NestJS API for a client that was returning Prisma error objects verbatim in API responses. These responses included the SQL query that failed, the database schema field names, and in one case the connection string error mentioning the database hostname. All of this was visible to anyone who sent a malformed request. An attacker learns your database schema, query patterns, and infrastructure details for free. Implement a global ExceptionFilter that catches all unhandled exceptions, logs the full error server-side, and returns a generic error response to the client. Never trust NestJS's default error handling for production.
Security-relevant events must be logged with enough context to investigate incidents. Use a structured logging library (Winston or Pino) with JSON output. Log: every authentication event (success/failure, IP, user agent), every authorization failure (who, what resource, why denied), every admin action (who, what action, on what resource, before/after state), every input validation failure (endpoint, fields, IP). Ship logs to a centralized log service (ELK stack, Loki, CloudWatch) — logs on the same server as the application can be deleted by an attacker. Retain security logs for 90 days minimum, 1 year for compliance.
Every production NestJS API I deploy gets: global ValidationPipe (whitelist, forbidNonWhitelisted, transform), Helmet security headers, rate limiting (throttler + Redis), CORS allowlist, JWT authentication guard, RBAC authorization guard, global ExceptionFilter (no stack traces in responses), structured audit logging (Winston + JSON), SQL injection prevention (Prisma ORM, validated raw queries only), input sanitization for XSS (strip HTML from string inputs), API versioning to allow security patches without breaking clients, and health check endpoint that does not leak system information. This covers the OWASP Top 10 controls most relevant to API-first applications.
Sources & Further Reading