Seguridad en NestJS

Por: Artiko
nestjsbuenas-practicasseguridadowaspautenticacionautorizacionjwt

Seguridad en NestJS

La mayoría de las vulnerabilidades graves en una API NestJS no son fallos exóticos del framework: son decisiones de calidad que se dejaron para después. Un guard que valida el rol pero no el dueño del recurso, un secreto commiteado en el repo, un endpoint de login sin límite de intentos, una entidad que se serializa entera con el hash de la contraseña incluido. Todas ellas caen dentro del OWASP API Security Top 10 (2023), el marco de referencia de este capítulo.

NestJS te da las piezas para cerrar cada uno de esos huecos —guards, interceptores, pipes, el ecosistema Passport— pero la responsabilidad de conectarlas correctamente es tuya. Este capítulo recorre el Top 10 traducido a mecánica concreta de Nest, con el antipatrón y su refactor lado a lado.

El mapa: OWASP API Top 10 y su capa en Nest

Cada riesgo de OWASP se mitiga en una capa distinta del ciclo de vida del request. Ubicar el control en la capa correcta es la mitad del trabajo.

flowchart LR
  Req[Request] --> MW[Middleware<br/>helmet, CORS]
  MW --> TG[ThrottlerGuard<br/>rate limiting]
  TG --> AG[AuthGuard<br/>autenticación]
  AG --> AZ[Authorization Guard<br/>RBAC + ownership]
  AZ --> VP[ValidationPipe<br/>whitelist, DTO]
  VP --> H[Handler]
  H --> SI[SerializerInterceptor<br/>@Exclude en respuesta]
  SI --> Res[Response]

  MW -.->|API8| owasp8[Misconfiguration]
  TG -.->|API4| owasp4[Resource Consumption]
  AG -.->|API2| owasp2[Broken Auth]
  AZ -.->|API1/API5| owasp1[BOLA / BFLA]
  VP -.->|API3| owasp3[Mass Assignment]
  SI -.->|API3| owasp3b[Excessive Data]
# OWASPRiesgoDónde se mitiga en Nest
API1Broken Object Level Authorization (BOLA/IDOR)Guard de autorización con chequeo de ownership
API2Broken AuthenticationAuthGuard, Passport, JWT, hashing de contraseñas
API3Broken Object Property Level AuthorizationValidationPipe whitelist + serialización con @Exclude
API4Unrestricted Resource Consumption@nestjs/throttler, límites de payload, paginación
API5Broken Function Level AuthorizationRolesGuard (RBAC)
API8Security Misconfigurationhelmet, CORS, secretos fuera del repo

API2 — Autenticación: Passport y JWT

NestJS integra Passport mediante @nestjs/passport, pero para una API stateless con JWT no necesitás toda la maquinaria de estrategias: @nestjs/jwt más un guard propio suele ser suficiente y más transparente.

Configuración del módulo JWT

El secreto nunca se hardcodea: viene del ConfigModule. Usá registerAsync para inyectar ConfigService.

import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
  imports: [
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
        signOptions: { expiresIn: '15m' },
      }),
    }),
  ],
})
export class AuthModule {}

Login: verificar contraseña y firmar el token

@Injectable()
export class AuthService {
  constructor(
    private readonly users: UsersService,
    private readonly jwt: JwtService,
    private readonly config: ConfigService,
  ) {}

  async login(email: string, password: string) {
    const user = await this.users.findByEmail(email);
    // Mensaje genérico: no revelar si el email existe (evita enumeración de usuarios)
    if (!user || !(await verify(user.passwordHash, password))) {
      throw new UnauthorizedException('Credenciales inválidas');
    }
    return this.issueTokens(user);
  }

  private async issueTokens(user: User) {
    const payload = { sub: user.id, role: user.role };
    const [accessToken, refreshToken] = await Promise.all([
      this.jwt.signAsync(payload, { expiresIn: '15m' }),
      this.jwt.signAsync(payload, {
        secret: this.config.getOrThrow('JWT_REFRESH_SECRET'),
        expiresIn: '7d',
      }),
    ]);
    return { accessToken, refreshToken };
  }
}

El guard de autenticación

El guard extrae el token del header Authorization: Bearer, lo verifica y coloca el payload en request.user. Un decorador @Public() marca rutas que saltan la verificación.

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(
    private readonly jwt: JwtService,
    private readonly reflector: Reflector,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;

    const request = context.switchToHttp().getRequest<Request>();
    const token = this.extractTokenFromHeader(request);
    if (!token) throw new UnauthorizedException();

    try {
      request['user'] = await this.jwt.verifyAsync(token);
    } catch {
      throw new UnauthorizedException();
    }
    return true;
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

Registralo como guard global para que la autenticación sea opt-out (@Public()) en vez de opt-in. Es más seguro por defecto: olvidarte de proteger una ruta no la deja abierta.

providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }],

Refresh tokens y dónde guardar el token

El access token es de vida corta (5-15 min). El refresh token es de vida larga y se usa una sola vez para emitir un nuevo par (rotación). Guardá un hash del refresh token en la base para poder revocarlo; si se presenta uno ya usado, invalidá toda la familia (detección de robo).

sequenceDiagram
  participant C as Cliente
  participant A as AuthController
  participant DB as Base de datos
  C->>A: POST /auth/login (email, password)
  A->>DB: buscar usuario + verificar hash (argon2)
  A-->>C: accessToken (15m) + refreshToken (7d, httpOnly cookie)
  Note over C: accessToken expira
  C->>A: POST /auth/refresh (refreshToken)
  A->>DB: validar hash del refresh + rotar
  A-->>C: nuevo accessToken + nuevo refreshToken

Dónde guardar el token en el cliente: el localStorage es accesible por cualquier script, así que un XSS roba el token directamente. La opción con menor superficie es una cookie httpOnly, Secure, SameSite=Strict para el refresh token (inaccesible a JS), y mantener el access token solo en memoria. Si usás cookies, activá protección CSRF (Nest documenta csrf-csrf para el patrón double-submit).

Hashing de contraseñas: argon2 sobre bcrypt

Nunca guardes contraseñas en texto plano ni con hashes de propósito general (MD5/SHA-256 son demasiado rápidos y se rompen por fuerza bruta). Usá una función de derivación diseñada para ser lenta. OWASP recomienda argon2id como primera opción; bcrypt es aceptable con un cost factor ≥ 12.

import { hash, verify } from 'argon2';

// Al registrar
const passwordHash = await hash(plainPassword); // argon2id por defecto

// Al login (verify hace la comparación en tiempo constante)
const ok = await verify(user.passwordHash, plainPassword);

Argon2 incorpora el salt en el propio hash y resiste ataques con GPU/ASIC gracias a su costo en memoria, algo que bcrypt no ofrece.

API1 — Autorización: el riesgo #1 es romper el ownership

Broken Object Level Authorization (BOLA), también conocida como IDOR, es el riesgo número 1 de OWASP por frecuencia e impacto. Ocurre cuando validás que el usuario está autenticado y tiene el rol correcto, pero no verificás que sea el dueño del recurso concreto que está pidiendo.

Antipatrón: autorización solo por rol

// ❌ ANTIPATRÓN: cualquier usuario con rol "user" lee CUALQUIER factura
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('user')
@Get('invoices/:id')
getInvoice(@Param('id') id: string) {
  return this.invoicesService.findById(id);
}

El RolesGuard responde “sí, es un user” y deja pasar. Pero GET /invoices/42 devuelve la factura 42 sin importar de quién sea. Cambiando el id en la URL, un atacante enumera datos de todos los usuarios. El rol es correcto; el objeto no le pertenece.

Refactor: validar ownership del recurso

El chequeo de rol (RBAC) responde qué acciones puede hacer un usuario; el chequeo de ownership responde sobre qué instancias. Necesitás ambos. La forma más limpia es en la capa de servicio, donde tenés acceso al recurso y a la identidad:

// ✅ MEJOR: el servicio compara el dueño del recurso con el usuario autenticado
async getInvoice(id: string, userId: string): Promise<Invoice> {
  const invoice = await this.repo.findById(id);
  if (!invoice) throw new NotFoundException();
  if (invoice.ownerId !== userId) {
    // 404, no 403: no confirmes la existencia del recurso a quien no es dueño
    throw new NotFoundException();
  }
  return invoice;
}
@Get('invoices/:id')
getInvoice(@Param('id') id: string, @CurrentUser() user: JwtPayload) {
  return this.invoicesService.getInvoice(id, user.sub);
}

Devolver 404 en vez de 403 cuando el recurso existe pero no es tuyo evita filtrar la existencia de IDs ajenos. Para lógica de ownership repetida, un guard dedicado que cargue el recurso y compare ownerId centraliza el patrón; los detalles de guards están en el capítulo 6.

RBAC con guards (API5)

Broken Function Level Authorization es el primo de BOLA a nivel de operación: un user que alcanza un endpoint de admin. RBAC con un guard y metadata resuelve esto.

export const Roles = (...roles: Role[]) => SetMetadata('roles', roles);

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const required = this.reflector.getAllAndOverride<Role[]>('roles', [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!required) return true;
    const { user } = context.switchToHttp().getRequest();
    return required.some((role) => user.role === role);
  }
}

ABAC con CASL para permisos finos

Cuando las reglas dependen de atributos (“un editor puede modificar solo artículos de su propio equipo y no publicados”), el RBAC plano se queda corto. CASL modela permisos como habilidades sobre sujetos con condiciones, y se integra con Nest vía un guard que evalúa la ability del usuario.

// Definición de habilidades por usuario
export function defineAbilityFor(user: User) {
  const { can, cannot, build } = new AbilityBuilder(createMongoAbility);
  if (user.role === 'admin') {
    can('manage', 'all');
  } else {
    can('read', 'Article');
    can('update', 'Article', { authorId: user.id, published: false });
    cannot('delete', 'Article');
  }
  return build();
}
// El guard resuelve la habilidad y la contrasta con la acción requerida
const ability = defineAbilityFor(request.user);
if (ability.cannot('update', subject('Article', article))) {
  throw new ForbiddenException();
}

CASL evalúa las condiciones contra la instancia concreta, así que cubre ownership y RBAC en un mismo modelo.

API3 — Validación de entrada y mass assignment

La Broken Object Property Level Authorization combina dos fallos: exponer propiedades que no deberías (excessive data exposure) y aceptar propiedades que no deberías (mass assignment).

Mass assignment: el whitelist como defensa

Si tu servicio hace this.repo.create(body) con el body crudo, un atacante manda { "email": "...", "role": "admin" } y se autoasciende. La defensa es el ValidationPipe global con whitelist, que descarta toda propiedad no declarada en el DTO (ver capítulo 5).

// ✅ Whitelist activa: solo email y password sobreviven; "role" se descarta
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true, // 400 si mandan campos extra, en vez de silenciar
    transform: true,
  }),
);
export class CreateUserDto {
  @IsEmail() email: string;
  @IsStrongPassword() password: string;
  // "role" NO está declarado -> nunca llega al servicio
}

Aunque el atacante incluya role en el JSON, sin whitelist el campo llega al repositorio; con whitelist, el pipe lo elimina antes del handler.

Excessive data exposure: serialización con class-transformer

El otro lado del problema: devolver la entidad completa —incluido el passwordHash— en la respuesta.

// ❌ ANTIPATRÓN: se serializa el objeto entero, hash de password incluido
@Get(':id')
findOne(@Param('id') id: string) {
  return this.usersService.findById(id); // { id, email, passwordHash, ... }
}

La solución es marcar los campos sensibles con @Exclude() y activar el ClassSerializerInterceptor, que aplica la transformación a lo que devuelve el handler.

import { Exclude } from 'class-transformer';

export class UserEntity {
  id: string;
  email: string;

  @Exclude()
  passwordHash: string;

  constructor(partial: Partial<UserEntity>) {
    Object.assign(this, partial);
  }
}
@UseInterceptors(ClassSerializerInterceptor) // o global vía APP_INTERCEPTOR
@Get(':id')
async findOne(@Param('id') id: string): Promise<UserEntity> {
  return new UserEntity(await this.usersService.findById(id));
}

El interceptor debe recibir una instancia de la clase (por eso el new UserEntity(...)); si devolvés un objeto plano, @Exclude() no se aplica. Alternativamente, mapeá siempre a un DTO de respuesta explícito que solo contenga campos públicos: es la opción con menor riesgo de fuga por olvido.

API4 — Rate limiting y consumo de recursos

Sin límite de peticiones, el endpoint de login queda expuesto a fuerza bruta y la API entera a DoS. @nestjs/throttler (v6) resuelve el rate limiting con throttlers nombrados.

Antipatrón: login sin rate limiting

// ❌ Un atacante prueba miles de contraseñas por segundo sin fricción
@Post('login')
login(@Body() dto: LoginDto) {
  return this.authService.login(dto.email, dto.password);
}

Refactor: throttler global + límite estricto en login

En v6 el ttl se expresa en milisegundos y forRoot acepta un array de throttlers nombrados.

import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';

@Module({
  imports: [
    ThrottlerModule.forRoot([
      { name: 'short', ttl: 1000, limit: 3 },
      { name: 'long', ttl: 60000, limit: 100 },
    ]),
  ],
  providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}
import { Throttle } from '@nestjs/throttler';

// Endpoint sensible: 5 intentos por minuto por IP
@Throttle({ default: { limit: 5, ttl: 60000 } })
@Post('login')
login(@Body() dto: LoginDto) {
  return this.authService.login(dto.email, dto.password);
}

Complementá el rate limiting con paginación obligatoria (nunca un findAll() sin take/limit) y límites de tamaño de payload (bodyParser con limit), que también caen bajo API4. Detrás de un proxy, configurá trust proxy para que el throttler use la IP real y no la del balanceador.

API8 — Security misconfiguration: helmet, CORS, secretos

helmet: cabeceras de seguridad

helmet setea cabeceras HTTP defensivas (Content-Security-Policy, X-Content-Type-Options, Strict-Transport-Security, etc.). Se aplica como middleware; antes de definir rutas.

import helmet from 'helmet';

const app = await NestFactory.create(AppModule);
app.use(helmet());

Con adaptador Fastify usá @fastify/helmet en su lugar. Recordá que helmet complementa pero no reemplaza el resto de controles.

CORS bien configurado

El antipatrón es origin: '*' combinado con credentials: true (que los navegadores rechazan, pero delata la intención de abrir todo). Definí una allowlist explícita.

app.enableCors({
  origin: ['https://app.miempresa.com'], // nunca '*' en producción con credenciales
  credentials: true,
  methods: ['GET', 'POST', 'PATCH', 'DELETE'],
});

CORS es una protección del navegador, no del servidor: no sustituye a la autenticación. Un cliente que no sea navegador ignora CORS por completo.

Secretos fuera del repositorio

// ❌ ANTIPATRÓN: secreto hardcodeado y commiteado al repo
const secret = 'sk_live_9f3a...'; // queda en el historial de git PARA SIEMPRE

Los secretos van en variables de entorno validadas al arranque, o en un secret manager (Vault, AWS Secrets Manager, Doppler). Nunca en el código, nunca en el bundle del frontend. Un .env fuera de git para desarrollo y el secret manager en producción. El manejo tipado y validado se cubre en el capítulo 11. Si un secreto se filtró al historial, no alcanza con borrarlo: hay que rotarlo.

Inyecciones: SQL, NoSQL y command injection

SQL injection

La regla es una sola: queries parametrizadas siempre, nunca concatenar entrada del usuario en el SQL. Los ORM modernos (Prisma, Drizzle, TypeORM con query builder) parametrizan por defecto; el riesgo aparece cuando escapás a SQL crudo (ver capítulo 8).

// ❌ ANTIPATRÓN: interpolación directa -> ' OR 1=1 --
await this.repo.query(`SELECT * FROM users WHERE email = '${email}'`);

// ✅ MEJOR: placeholders parametrizados
await this.repo.query('SELECT * FROM users WHERE email = $1', [email]);
// Prisma tagged template también parametriza:
await this.prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`;

NoSQL injection

En MongoDB, aceptar un objeto crudo como filtro permite inyectar operadores. Si email llega como { "$ne": null }, el filtro devuelve cualquier usuario. La defensa: castear a tipos primitivos con el DTO (@IsString()), no pasar el body directo al query.

// ❌ req.body.email podría ser { $gt: '' }
this.userModel.findOne({ email: req.body.email });

// ✅ el DTO fuerza string; el operador de inyección es rechazado por el pipe
this.userModel.findOne({ email: dto.email });

Command injection

Nunca pases entrada del usuario a exec() con la shell. Usá execFile/spawn con argumentos como array, que no interpreta metacaracteres.

// ❌ exec(`convert ${userFile} out.png`)  -> "; rm -rf /" se ejecuta
// ✅ execFile('convert', [userFile, 'out.png'])  -> userFile es un argumento, no shell

Otros controles

Checklist

La seguridad depende de que los secretos y la configuración estén bien gestionados: dónde viven, cómo se validan al arranque y cómo cambian entre entornos. Eso es exactamente lo que resolvemos en Configuración →.