# Nestjs Security

> Implements and audits security hardening for NestJS applications. Use when building authentication, authorization, input validation, rate limiting, secrets management, or auditing sensitive endpoints. Triggers on requests like "secure my NestJS API", "add JWT auth", "implement RBAC", "rate limit endpoints", "sanitize inputs", "protect against SQL injection/XSS", or any security-sensitive feature. Covers OWASP Top 10 in a NestJS context.

- Skill: `namds29/nestjs-security` (Agent Skill)
- Install (CLI): `npx skillmds@latest add namds29/nestjs-security`
- Raw SKILL.md: https://api.skillmd.com/api/skills/namds29/nestjs-security/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Data & Analytics
- Author: namds29 (https://skillmd.com/u/namds29)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/namds29/nestjs-security

---


# NestJS Security Skill

When generating or reviewing NestJS security code, apply defense-in-depth: every layer of the application must validate and protect independently. Never rely on a single guard as the only line of defense.

## Security Layer Map

```
Security concerns by layer:
├── Network/Infra    → Helmet, CORS, TLS termination, rate limiting
├── Authentication   → JWT, refresh tokens, session invalidation
├── Authorization    → RBAC/ABAC guards, policy enforcement
├── Input            → class-validator, class-transformer, file validation
├── Data             → Parameterized queries, field-level encryption
├── Secrets          → ConfigService, vault integration, no .env in repo
└── Audit            → Structured logging, sensitive-action tracking
```

## 1. Bootstrap Hardening

```typescript
// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    logger: ["error", "warn"], // never log 'verbose' or 'debug' in production
  });

  // Helmet — sets secure HTTP headers
  app.use(
    helmet({
      contentSecurityPolicy: {
        directives: {
          defaultSrc: ["'self'"],
          scriptSrc: ["'self'"],
          objectSrc: ["'none'"],
          upgradeInsecureRequests: [],
        },
      },
      hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
    }),
  );

  // CORS — explicit allowlist only
  app.enableCors({
    origin: process.env.ALLOWED_ORIGINS?.split(",") ?? [],
    methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
    credentials: true,
  });

  // Global validation pipe — always whitelist, always transform
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true, // strip unknown properties
      forbidNonWhitelisted: true, // throw on unknown properties
      transform: true, // auto-transform types
      transformOptions: { enableImplicitConversion: false },
    }),
  );

  // Global exception filter — never leak stack traces
  app.useGlobalFilters(new GlobalExceptionFilter());

  await app.listen(3000, "0.0.0.0");
}
```

## 2. Authentication — JWT + Refresh Token Rotation

```typescript
// infrastructure/auth/strategies/jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: config.getOrThrow<string>("JWT_SECRET"), // throws if missing
      algorithms: ["HS256"],
    });
  }

  async validate(payload: JwtPayload): Promise<AuthUser> {
    // Always re-validate user existence and status on each request
    const user = await this.userRepo.findById(payload.sub);
    if (!user || user.status !== UserStatus.ACTIVE)
      throw new UnauthorizedException();
    return { id: user.id, roles: user.roles };
  }
}
```

```typescript
// Refresh token — store hashed, rotate on every use
@Injectable()
export class AuthService {
  async refreshTokens(userId: string, rawRefreshToken: string) {
    const stored = await this.tokenRepo.findByUserId(userId);
    if (!stored) throw new ForbiddenException();

    const isValid = await bcrypt.compare(rawRefreshToken, stored.hashedToken);
    if (!isValid) {
      // Possible token reuse attack — invalidate ALL tokens for this user
      await this.tokenRepo.deleteAllForUser(userId);
      throw new ForbiddenException("Refresh token reuse detected");
    }

    // Rotate: delete old, issue new
    await this.tokenRepo.delete(stored.id);
    return this.issueTokens(userId);
  }

  private async issueTokens(userId: string) {
    const [accessToken, refreshToken] = await Promise.all([
      this.jwt.signAsync({ sub: userId }, { expiresIn: "15m" }),
      this.jwt.signAsync(
        { sub: userId },
        {
          expiresIn: "7d",
          secret: this.config.getOrThrow("JWT_REFRESH_SECRET"),
        },
      ),
    ]);
    const hashed = await bcrypt.hash(refreshToken, 12);
    await this.tokenRepo.save({ userId, hashedToken: hashed });
    return { accessToken, refreshToken };
  }
}
```

## 3. Authorization — RBAC with Policy Guards

```typescript
// shared/decorators/roles.decorator.ts
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

// infrastructure/http/guards/roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!requiredRoles?.length) return true;

    const { user } = context.switchToHttp().getRequest<AuthenticatedRequest>();
    return requiredRoles.some(role => user?.roles?.includes(role));
  }
}

// Usage in controller
@Roles(Role.ADMIN)
@UseGuards(JwtAuthGuard, RolesGuard)
@Delete(':id')
async deleteUser(@Param('id') id: string) { ... }
```

## 4. Input Validation & Sanitization

```typescript
// application/dtos/create-article.dto.ts
import { Transform } from "class-transformer";
import { IsString, MaxLength, Matches } from "class-validator";
import sanitizeHtml from "sanitize-html";

export class CreateArticleDto {
  @IsString()
  @MaxLength(200)
  @Matches(/^[\w\s\-.,!?]+$/, { message: "Title contains invalid characters" })
  title: string;

  @IsString()
  @MaxLength(50000)
  @Transform(({ value }) =>
    sanitizeHtml(value, {
      allowedTags: ["b", "i", "em", "strong", "p", "ul", "ol", "li"],
      allowedAttributes: {}, // no attributes at all
    }),
  )
  content: string;

  // Never trust client-supplied IDs for ownership — derive from JWT instead
  // authorId comes from @CurrentUser() decorator, NOT from body
}
```

### File Upload Validation

```typescript
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async upload(@UploadedFile(
  new ParseFilePipe({
    validators: [
      new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }), // 5MB
      new FileTypeValidator({ fileType: /^image\/(jpeg|png|webp)$/ }),
    ],
  }),
) file: Express.Multer.File) {
  // Rename with uuid — never use original filename
  const safeName = `${randomUUID()}.${mime.extension(file.mimetype)}`;
  return this.uploadService.save(file.buffer, safeName);
}
```

## 5. Rate Limiting

```typescript
// app.module.ts
ThrottlerModule.forRootAsync({
  useFactory: (config: ConfigService) => [{
    name: 'default',
    ttl: 60_000,
    limit: 100,
  }, {
    name: 'auth',       // stricter limit for auth endpoints
    ttl: 60_000,
    limit: 10,
  }],
  inject: [ConfigService],
}),

// On the auth controller
@Throttle({ auth: { limit: 5, ttl: 60_000 } })
@Post('login')
async login(@Body() dto: LoginDto) { ... }
```

## 6. Secrets Management

```typescript
// infrastructure/config/config.schema.ts — validate all env vars at startup
import * as Joi from "joi";

export const configSchema = Joi.object({
  NODE_ENV: Joi.string().valid("development", "production", "test").required(),
  DB_PASSWORD: Joi.string().min(24).required(),
  JWT_SECRET: Joi.string().min(64).required(),
  JWT_REFRESH_SECRET: Joi.string().min(64).required(),
  ENCRYPTION_KEY: Joi.string().length(64).required(), // 32 bytes hex
});

// app.module.ts
ConfigModule.forRoot({
  validationSchema: configSchema,
  validationOptions: { abortEarly: false },
  isGlobal: true,
});
```

Rules:

- Never hardcode secrets or use `process.env.X` directly — always use `ConfigService.getOrThrow()`
- Rotate secrets without downtime using dual-secret validation during rotation window
- Use AWS Secrets Manager / HashiCorp Vault in production; `.env` only for local dev and must be in `.gitignore`

## 7. Audit Logging

```typescript
// infrastructure/http/interceptors/audit.interceptor.ts
@Injectable()
export class AuditInterceptor implements NestInterceptor {
  constructor(private readonly logger: Logger) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
    const sensitive = Reflect.getMetadata(AUDIT_KEY, context.getHandler());
    if (!sensitive) return next.handle();

    const start = Date.now();
    return next.handle().pipe(
      tap({
        next: () => this.log(req, 'SUCCESS', Date.now() - start),
        error: (err) => this.log(req, 'FAILURE', Date.now() - start, err),
      }),
    );
  }

  private log(req: AuthenticatedRequest, outcome: string, ms: number, err?: unknown) {
    this.logger.log({
      type: 'AUDIT',
      userId: req.user?.id,
      ip: req.ip,
      method: req.method,
      path: req.path,
      outcome,
      durationMs: ms,
      error: err instanceof Error ? err.message : undefined,
      // NEVER log req.body — may contain passwords, PII
    });
  }
}

// Usage
@Audit()  // custom decorator that sets AUDIT_KEY metadata
@Post('transfer')
async transfer(@Body() dto: TransferDto) { ... }
```

## 8. Global Exception Filter — No Stack Trace Leakage

```typescript
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  constructor(private readonly logger: Logger) {}

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const res = ctx.getResponse<Response>();
    const req = ctx.getRequest<Request>();

    const isHttpException = exception instanceof HttpException;
    const status = isHttpException ? exception.getStatus() : 500;

    // Log full error internally
    this.logger.error({ exception, path: req.path });

    // Return sanitized response — never expose internals
    res.status(status).json({
      statusCode: status,
      message: isHttpException ? exception.message : "Internal server error",
      timestamp: new Date().toISOString(),
      path: req.path,
      // No stack, no internal error details
    });
  }
}
```

## Security Review Checklist

When auditing NestJS code for security:

1. **Auth on every route** — Are all non-public routes protected by `JwtAuthGuard`? Is there a `@Public()` decorator for intentional exceptions, and is it documented?
2. **Input validation** — Does every DTO use `class-validator`? Is `whitelist: true` set globally? Are file uploads type-checked and size-limited?
3. **No raw queries** — Are all DB queries using parameterized ORM methods? Search for raw SQL strings and template literals with user input.
4. **Secret hygiene** — Is `ConfigService.getOrThrow()` used everywhere? Search for `process.env` direct access.
5. **Refresh token rotation** — Are refresh tokens hashed at rest and rotated on each use? Is token reuse detected and handled by invalidating the session?
6. **Rate limiting** — Are auth endpoints under stricter throttle limits than general API?
7. **Error responses** — Do error responses ever include stack traces, DB errors, or internal paths?
8. **CORS allowlist** — Is CORS configured with an explicit origin list, not `origin: true`?
9. **Audit trail** — Are sensitive actions (payment, role change, data export, admin actions) decorated with `@Audit()`?
10. **Dependency scan** — Is `npm audit` part of the CI pipeline? Are known-vulnerable packages patched?

## How to provide feedback

- Cite the specific OWASP category or CWE the issue falls under
- Show the vulnerable code path end-to-end (route → guard → service)
- Provide a corrected snippet, not just a description
- Note the severity: Critical (data breach risk), High (auth bypass), Medium (info leakage), Low (hardening improvement)

