OWASP Top 10 para backends de Node.js: guía práctica de seguridad para 2026
Introducción
El OWASP Top 10 es el estándar de referencia de la industria para la seguridad de aplicaciones web — pero sus descripciones genéricas dejan a los equipos de Node.js adivinando cómo se aplica cada categoría a su stack. ¿Significa "Control de acceso roto" lo mismo en Express que en un contenedor de servlets de Java? ¿Qué aspecto tiene el "Diseño inseguro" en un microservicio de JavaScript?
Esta guía traduce el OWASP Top 10 de 2021 en patrones concretos y accionables para backends de Node.js. Para cada categoría verás el código vulnerable real, cómo lo explotaría un atacante y la corrección de nivel de producción. Úsala como referencia rápida de tu equipo durante auditorías de seguridad y revisiones de código.
A01:2021 — Control de acceso roto (Broken Access Control)
El control de acceso es la vulnerabilidad más prevalente del OWASP Top 10 — y la más mal configurada en aplicaciones Node.js. El error clásico: confiar en la información de rol del lado del cliente.
Patrón vulnerable
// [X] DANGEROUS: Trusts client-provided role
app.get('/api/admin/users', (req, res) => {
// The role comes from a decoded JWT that wasn't validated
// or worse — from a query parameter
if (req.query.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
const users = await db.user.findMany();
res.json(users);
});
Un atacante puede simplemente cambiar el parámetro role y escalar privilegios.
La corrección
// [V] SECURE: Server-enforced access control
const jwt = require('jsonwebtoken');
function requireRole(...allowedRoles) {
return (req, res, next) => {
// Extract role from the server-validated JWT payload
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!allowedRoles.includes(decoded.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
req.user = decoded;
next();
} catch {
return res.status(401).json({ error: 'Invalid token' });
}
};
}
app.get('/api/admin/users', requireRole('admin'), async (req, res) => {
const users = await db.user.findMany();
res.json(users);
});
Reglas clave:
- Nunca confíes en
req.query,req.bodyoreq.paramspara decisiones de autorización - Valida los roles únicamente a partir de tokens firmados por el servidor o datos de sesión
- Aplica el control de acceso en cada endpoint, no solo en el frontend
- Prueba los casos negativos: sin autenticar, rol incorrecto, token caducado
A02:2021 — Fallos criptográficos (Cryptographic Failures)
Node.js hace que sea sorprendentemente fácil equivocarse con la criptografía. Los fallos más comunes son el hash débil de contraseñas, usar crypto.createHash en lugar de un KDF adecuado y codificar secretos en el código.
Patrón vulnerable
// [X] DANGEROUS: Weak password hashing
const crypto = require('crypto');
function hashPassword(password) {
return crypto.createHash('sha256').update(password).digest('hex');
// SHA-256 is designed for speed — an attacker can try billions of guesses per second
}
La corrección
// [V] SECURE: Proper password hashing with bcrypt
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12; // Higher = slower. 12 is the current sweet spot.
async function hashPassword(password) {
const salt = await bcrypt.genSalt(SALT_ROUNDS);
return bcrypt.hash(password, salt);
}
async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
Reglas criptográficas adicionales:
- Usa
crypto.timingSafeEqual()para comparar valores sensibles (previene ataques de temporización) - Nunca codifiques secretos en el código — usa variables de entorno o un gestor de secretos como Vault
- Cifra los datos en reposo con AES-256-GCM (cifrado autenticado)
- Usa TLS 1.3 exclusivamente; desactiva TLS 1.0/1.1 en tu balanceador de carga
// Timing-safe comparison example
function safeCompare(userInput, actualSecret) {
if (userInput.length !== actualSecret.length) {
// Don't short-circuit on length — still compare to prevent timing oracle
return crypto.timingSafeEqual(
Buffer.from(userInput),
Buffer.from(actualSecret)
);
}
return crypto.timingSafeEqual(
Buffer.from(userInput),
Buffer.from(actualSecret)
);
}
A03:2021 — Inyección (Injection)
La inyección SQL/NoSQL sigue siendo devastadora en aplicaciones Node.js, especialmente con consultas MongoDB en bruto y SQL concatenado con cadenas.
Patrón vulnerable
// [X] DANGEROUS: String interpolation in MongoDB
app.get('/api/user', async (req, res) => {
const { username } = req.query;
// Direct string interpolation — attacker can inject $ne, $gt, $regex
const user = await db.collection('users').findOne({
username: { $eq: username }
});
// If username = "admin", this works fine.
// If username = { "$ne": "" }, it logs in as ANY user!
});
La corrección
// [V] SECURE: Validate and sanitize all inputs
const { ObjectId } = require('mongodb');
app.get('/api/user', async (req, res) => {
const { username } = req.query;
// 1. Validate type — reject objects and arrays
if (typeof username !== 'string' || username.length > 64) {
return res.status(400).json({ error: 'Invalid username' });
}
// 2. Use parameterized queries (Prisma / Mongoose handle this)
const user = await db.collection('users').findOne({
username: { $eq: username } // Safe: $eq prevents operator injection
});
// 3. Don't include sensitive fields in responses
if (user) {
const { passwordHash, tokenVersion, ...safeUser } = user;
return res.json(safeUser);
}
res.status(404).json({ error: 'User not found' });
});
Para bases de datos SQL, usa siempre consultas parametrizadas:
// [V] SECURE: Parameterized SQL with node-postgres
app.get('/api/user', async (req, res) => {
const { id } = req.params;
// Validate that id is a number
if (!/^\d+$/.test(id)) {
return res.status(400).json({ error: 'Invalid ID' });
}
const result = await pool.query(
'SELECT id, name, email FROM users WHERE id = $1',
[id] // Parameterized — never interpolated
);
res.json(result.rows[0] || null);
});
A04:2021 — Diseño inseguro (Insecure Design)
El diseño inseguro tiene que ver con fallos arquitectónicos más que con bugs de implementación. En Node.js, se manifiesta sobre todo como ausencia de limitación de velocidad (rate limiting) en endpoints críticos, consumo ilimitado de recursos y confianza en IDs generados por el cliente.
Patrón vulnerable
// [X] DANGEROUS: Missing rate limit on password reset
app.post('/api/auth/forgot-password', async (req, res) => {
const { email } = req.body;
const user = await db.user.findUnique({ where: { email } });
if (user) {
await sendPasswordResetEmail(email);
}
// Even though we don't reveal if the user exists,
// an attacker can flood the email service and exhaust quota
return res.json({ message: 'If the account exists, a reset email was sent.' });
});
La corrección
// [V] SECURE: Rate-limit critical endpoints and enforce throttling
const rateLimit = require('express-rate-limit');
const passwordResetLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 3, // 3 attempts per window per IP
message: { error: 'Too many requests. Try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
app.post(
'/api/auth/forgot-password',
passwordResetLimiter,
async (req, res) => {
const { email } = req.body;
const user = await db.user.findUnique({ where: { email } });
if (user) {
// Store reset token with expiration
const token = crypto.randomBytes(32).toString('hex');
await db.passwordReset.create({
data: {
email,
token,
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
},
});
// Queue email send asynchronously
emailQueue.add({ to: email, token });
}
return res.json({
message: 'If the account exists, a reset email was sent.',
});
}
);
Lista de verificación en tiempo de diseño:
- Cada endpoint de mutación necesita limitación de velocidad (rate limiting)
- Los endpoints de subida de archivos necesitan límites de tamaño y validación de tipo
- Los endpoints de paginación necesitan un tamaño máximo de página impuesto
- Las conexiones WebSocket necesitan límites de conexión por cliente
A05:2021 — Configuración de seguridad incorrecta (Security Misconfiguration)
Las aplicaciones Node.js salen a producción con un número sorprendente de ajustes por defecto que debilitan la seguridad. Los tres grandes: políticas CORS por defecto, mensajes de error verbosos y metadatos de dependencias expuestos.
Patrones vulnerables
// [X] DANGEROUS: Overly permissive CORS during development, promoted to production
app.use(cors()); // Accepts any origin by default!
// [X] DANGEROUS: Verbose error responses leak stack traces
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message, stack: err.stack });
});
La corrección
// [V] SECURE: Strict CORS with explicit origin allowlist
const cors = require('cors');
const ALLOWED_ORIGINS = [
'https://app.yourstartup.com',
'https://admin.yourstartup.com',
];
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (server-to-server, mobile apps)
if (!origin) return callback(null, true);
if (ALLOWED_ORIGINS.includes(origin)) {
return callback(null, true);
}
return callback(new Error('Not allowed by CORS'));
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
// [V] SECURE: Sanitize error responses
app.use((err, req, res, next) => {
console.error('[ERROR]', err); // Log the full error internally
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message,
});
});
Endurecimiento adicional:
- Elimina la cabecera
X-Powered-By: Express - Desactiva el listado de directorios en el servicio de archivos estáticos de Express
- Configura el middleware
Helmetpara cabeceras HTTP seguras - Nunca ejecutes
npm install --productionen el mismo paso que las instalaciones de desarrollo en CI
// Hardening middleware with Helmet
const helmet = require('helmet');
app.use(helmet());
app.disable('x-powered-by');
A06:2021 — Componentes vulnerables y desactualizados (Vulnerable and Outdated Components)
Esta es la superficie de ataque más amplia del ecosistema Node.js. Una aplicación típica de startup tiene entre 500 y 1500 dependencias directas y transitivas. Un solo paquete olvidado con un CVE conocido puede comprometer toda tu aplicación.
El enfoque
# Scan for known vulnerabilities
npm audit # Basic scan (covers direct deps)
npm audit --audit-level=high # Fail CI on high-severity issues
# Or use Snyk for deeper analysis
npx snyk test --all-projects # Scopes transitive deps too
npx snyk monitor # Continuous monitoring via Snyk dashboard
Higiene de dependencias de producción:
- Fija versiones exactas en
package.json(elimina^y~para dependencias críticas) - Usa
npm ls --depth=0semanalmente para auditar tu árbol de dependencias directas - Configura Dependabot o Renovate para que creen PR automáticamente para dependencias vulnerables
- Elimina dependencias sin usar con
depcheckonpm prune - Para paquetes con bindings nativos (node-gyp), fija explícitamente las versiones mayores
{
"dependencies": {
"express": "4.20.0",
"jsonwebtoken": "9.0.2",
"mongoose": "8.5.0"
}
}
A07:2021 — Fallos de identificación y autenticación (Identification and Authentication Failures)
Ya cubrimos la autenticación en profundidad en la guía Auth & Session Management, pero los escollos específicos de Node.js merecen mención propia.
Patrón vulnerable
// [X] DANGEROUS: Session fixation — accepting pre-set session IDs
app.post('/api/auth/login', async (req, res) => {
const { email, password, sessionId } = req.body;
const user = await authenticateUser(email, password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
// Reuses the client-provided session ID — attacker can set this before login!
await db.session.create({
data: { id: sessionId || crypto.randomUUID(), userId: user.id },
});
res.cookie('session_id', sessionId, { httpOnly: true });
});
La corrección
// [V] SECURE: Always generate fresh session IDs server-side
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await authenticateUser(email, password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
// Always generate a NEW session ID on login
const session = await db.session.create({
data: {
id: crypto.randomUUID(),
userId: user.id,
createdAt: new Date(),
lastActivity: new Date(),
},
});
res.cookie('session_id', session.id, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000,
});
res.json({ user: { id: user.id, name: user.name, email: user.email } });
});
A08:2021 — Fallos de integridad del software y de los datos (Software and Data Integrity Failures)
En Node.js, esto significa principalmente ataques a la cadena de suministro — paquetes npm comprometidos que ejecutan código malicioso durante la instalación o en tiempo de ejecución.
Medidas defensivas
# 1. Lock dependencies with package-lock.json (committed to git!)
npm install
# 2. Enable npm integrity verification
npm config set audit true
npm config set fund false
# 3. Use exact versions for production dependencies
npm config set save-exact true
// [V] SECURE: Validate webhook payloads with a secret
const crypto = require('crypto');
app.post('/api/webhooks/github', (req, res) => {
const signature = req.headers['x-hub-signature-256'];
const payload = JSON.stringify(req.body);
const expected = `sha256=${
crypto.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(payload)
.digest('hex')
}`;
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process webhook safely
res.status(200).end();
});
A09:2021 — Fallos de registro y monitoreo (Logging and Monitoring Failures)
No puedes responder a una brecha que no detectas. La mayoría de las startups de Node.js registran agresivamente en desarrollo y no registran nada en producción — o peor, registran datos sensibles.
Patrón vulnerable
// [X] DANGEROUS: Logging sensitive data
app.use((req, res, next) => {
console.log(`${req.method} ${req.path}`, req.body);
// req.body may contain passwords, tokens, credit cards...
next();
});
La corrección
// [V] SECURE: Structured logging with sensitive data redaction
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
redact: {
paths: [
'req.headers.authorization',
'req.body.password',
'req.body.token',
'req.body.secret',
'req.body.creditCard',
],
censor: '[REDACTED]',
},
transport: {
target: 'pino-pretty',
options: { colorize: process.env.NODE_ENV !== 'production' },
},
});
app.use((req, res, next) => {
logger.info({ req, method: req.method, path: req.path }, 'Incoming request');
next();
});
// Monitor security events specifically
function auditLog(event, metadata) {
logger.warn({ event, metadata, timestamp: new Date().toISOString() },
'Security event');
}
// Usage
auditLog('LOGIN_FAILURE', { email: req.body.email, ip: req.ip });
auditLog('ACCESS_DENIED', { userId: req.user?.id, resource: req.path });
Elementos esenciales de monitoreo:
- Agregación centralizada de logs (Sentry, DataDog o stack ELK)
- Alertas en tiempo real para: respuestas 401/403 repetidas, patrones de IP inusuales, tasas de error elevadas
- Replay de sesión o pista de auditoría para operaciones sensibles (transferencias, cambios de cuenta)
- Política de retención de logs alineada con tus requisitos de cumplimiento (SOC 2, GDPR)
A10:2021 — Server-Side Request Forgery (SSRF)
El SSRF es cada vez más peligroso en aplicaciones Node.js nativas de la nube que obtienen URLs proporcionadas por el usuario, especialmente cuando se despliegan en AWS/GCP donde los endpoints de metadatos son accesibles desde dentro de la VPC.
Patrón vulnerable
// [X] DANGEROUS: Fetching user-supplied URLs
app.post('/api/fetch-meta', async (req, res) => {
const { url } = req.body;
// An attacker can submit:
// http://169.254.169.254/latest/meta-data/iam/security-credentials/
// ...and steal your cloud provider's IAM credentials
const response = await fetch(url);
const data = await response.text();
res.json({ data });
});
La corrección
// [V] SECURE: Validate and restrict outbound URLs
const { URL } = require('url');
const ALLOWED_HOSTS = [
'api.github.com',
'api.stripe.com',
'api.sendgrid.com',
];
const BLOCKED_IPS = ['169.254.169.254', '127.0.0.1', '0.0.0.0', '::1'];
const PRIVATE_RANGES = [/^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./];
const dns = require('dns').promises;
async function validateExternalUrl(urlString) {
let url;
try {
url = new URL(urlString);
} catch {
throw new Error('Invalid URL');
}
// Only allow HTTPS
if (url.protocol !== 'https:') {
throw new Error('Only HTTPS URLs are allowed');
}
// Only allow specific hosts
if (!ALLOWED_HOSTS.includes(url.hostname)) {
throw new Error('Host not in allowlist');
}
// Resolve DNS and block private IPs
const addresses = await dns.resolve4(url.hostname);
for (const ip of addresses) {
if (BLOCKED_IPS.includes(ip)) {
throw new Error('Blocked IP address');
}
if (PRIVATE_RANGES.some(range => range.test(ip))) {
throw new Error('Private IP ranges are blocked');
}
}
return url;
}
app.post('/api/fetch-meta', async (req, res) => {
try {
const validatedUrl = await validateExternalUrl(req.body.url);
const response = await fetch(validatedUrl, {
signal: AbortSignal.timeout(5000),
});
const data = await response.text();
res.json({ data });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
Lista de verificación de auditoría OWASP Top 10 para Node.js
Usa esta lista en tu próxima revisión de seguridad de Node.js:
- [ ] A01 — Control de acceso roto: Cada endpoint aplica la autorización en el servidor. No se confía en ningún rol/claim enviado por el cliente.
- [ ] A02 — Fallos criptográficos: Contraseñas con hash bcrypt (≥12 rondas) o Argon2. Todos los secretos en variables de entorno o vault. AES-256-GCM para datos en reposo.
- [ ] A03 — Inyección: Sin inyección de operadores SQL/Mongo en bruto. Todas las consultas usan entradas parametrizadas o abstracciones de ORM.
- [ ] A04 — Diseño inseguro: Limitación de velocidad (rate limiting) en todos los endpoints de mutación. Tamaño máximo de paginación impuesto. Subidas de archivos validadas por tipo y tamaño.
- [ ] A05 — Configuración de seguridad incorrecta: CORS con lista blanca de orígenes. Las respuestas de error ocultan los internals en producción. Middleware Helmet activo. X-Powered-By eliminado.
- [ ] A06 — Componentes vulnerables:
npm auditpasa en nivel high/severity. Dependencias fijadas a versiones exactas. Sin paquetes sin usar. Dependabot activo. - [ ] A07 — Fallos de autenticación: IDs de sesión generados por el servidor y rotados en login/logout. Timeouts de inactividad (30 min) y absolutos (24 h) configurados.
- [ ] A08 — Fallos de integridad:
package-lock.jsoncommiteado. Firmas de webhook verificadas. Comprobaciones de integridad de npm habilitadas. - [ ] A09 — Registro y monitoreo: Registro estructurado con redacción. Eventos de seguridad (inicios de sesión fallidos, denegaciones de acceso) auditados. Alertas configuradas.
- [ ] A10 — SSRF: Peticiones HTTP salientes solo a hosts de la lista blanca. Rangos de IP privados bloqueados. HTTPS obligatorio. Timeouts configurados.
Conclusión
El OWASP Top 10 no es un marco teórico — es un mapa de las vulnerabilidades que realmente se explotan en producción. Para los equipos de Node.js, cada categoría se corresponde con patrones de código concretos que puedes auditar, corregir y verificar en CI.
Empieza con la lista de verificación anterior en tu próximo sprint. Incluso corregir las tres primeras categorías (Control de acceso roto, Fallos criptográficos e Inyección) elimina la mayoría de las vulnerabilidades de severidad crítica que se encuentran en las auditorías de producción.
El coste de corregir esto pronto se mide en horas de refactorización. El coste de encontrarlo en una prueba de penetración — o peor, en una notificación de brecha — se mide en daño reputacional, pérdida de clientes y responsabilidad legal. Elige una categoría esta semana, audita tu base de código y despliega la corrección. Tu yo futuro (y tus usuarios) te lo agradecerán.
JS Security Audit
Auditorías dirigidas por un ingeniero de seguridad JavaScript senior con más de 10 años de experiencia.