Seguridad en NestJS
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]
| # OWASP | Riesgo | Dónde se mitiga en Nest |
|---|---|---|
| API1 | Broken Object Level Authorization (BOLA/IDOR) | Guard de autorización con chequeo de ownership |
| API2 | Broken Authentication | AuthGuard, Passport, JWT, hashing de contraseñas |
| API3 | Broken Object Property Level Authorization | ValidationPipe whitelist + serialización con @Exclude |
| API4 | Unrestricted Resource Consumption | @nestjs/throttler, límites de payload, paginación |
| API5 | Broken Function Level Authorization | RolesGuard (RBAC) |
| API8 | Security Misconfiguration | helmet, 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
- Logging sin datos sensibles: nunca logees contraseñas, tokens, números de tarjeta ni PII. Usá un logger estructurado con redacción de campos (
pinoconredact). El log es un canal de fuga tan real como la respuesta HTTP. - Dependencias vulnerables: corré
npm audit(obun audit) en CI y mantené las dependencias al día; una lib con un CVE conocido es la puerta de entrada más barata. - Mensajes de error genéricos: en producción, no devuelvas stack traces ni detalles internos. El
exception filterglobal debe mapear a mensajes neutros; los detalles van al log. - HTTPS y
Strict-Transport-Security: forzá TLS de punta a punta; un token viaja en claro sin él.
Checklist
- Contraseñas hasheadas con argon2id (o bcrypt cost ≥ 12), nunca en texto plano ni con SHA/MD5.
- Access token de vida corta + refresh token rotado y revocable; refresh en cookie
httpOnly/Secure/SameSite. - Mensaje de login genérico (“credenciales inválidas”) para no permitir enumeración de usuarios.
- Cada acceso a un recurso valida ownership, no solo el rol (defensa contra BOLA/IDOR); 404 en vez de 403 para recursos ajenos.
- RBAC con
RolesGuardpara autorización a nivel de función; CASL/ABAC donde las reglas dependan de atributos. -
ValidationPipeglobal conwhitelist: trueyforbidNonWhitelisted: truecontra mass assignment. - Respuestas serializadas con
@Exclude()+ClassSerializerInterceptor, o mapeadas a DTO de salida; nunca la entidad con el hash. -
@nestjs/throttlerglobal + límite estricto en login/endpoints sensibles; paginación y límite de payload obligatorios. -
helmet()aplicado y CORS con allowlist explícita (nuncaorigin: '*'con credenciales). - Queries parametrizadas (SQL), DTOs tipados (NoSQL),
execFile/spawncon args (command injection). - Secretos en variables de entorno o secret manager, nunca en el repo; rotación si hubo filtración.
-
npm auditen CI, logs sin datos sensibles, errores genéricos en producción, TLS forzado.
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 →.