En 2024, un cliente nos contactó después de sufrir una brecha de seguridad. Un atacante había explotado una inyección SQL en un formulario de búsqueda — una vulnerabilidad que aparece en el puesto número 3 del OWASP Top 10 desde hace más de una década. No era un ataque sofisticado. Era un fallo prevenible.
Esa experiencia reforzó algo que ya sabíamos: la seguridad no es una fase del proyecto, es una disciplina que se practica en cada línea de código. En este artículo compartimos las prácticas de seguridad que aplicamos en cada proyecto, organizadas por fase del desarrollo.
El OWASP Top 10: Punto de partida, no destino
El OWASP Top 10 es la referencia mínima que todo equipo de desarrollo debería conocer. Estos son los riesgos más críticos en 2025 y cómo los abordamos:
| # | Riesgo | Nuestra defensa principal | |---|--------|---------------------------| | A01 | Broken Access Control | Middleware de autorización por recurso, tests de acceso | | A02 | Cryptographic Failures | TLS obligatorio, cifrado AES-256 en reposo, rotación de claves | | A03 | Injection | Queries parametrizadas, ORM, validación de entrada | | A04 | Insecure Design | Threat modeling en fase de diseño | | A05 | Security Misconfiguration | IaC con seguridad por defecto, escaneo automático | | A06 | Vulnerable Components | Dependabot, auditorías semanales, lockfiles | | A07 | Auth Failures | MFA, rate limiting, detección de fuerza bruta | | A08 | Data Integrity Failures | Firma de artefactos, verificación de integridad en CI/CD | | A09 | Logging Failures | Logging estructurado, alertas de seguridad, retención | | A10 | SSRF | Whitelist de destinos, validación de URLs, red privada |
El Top 10 cubre los riesgos más comunes, pero la seguridad real va mucho más allá. Veamos cada área en profundidad.
Inyección: El ataque que nunca pasa de moda
La inyección SQL sigue siendo devastadoramente efectiva porque muchos desarrolladores todavía concatenan strings para construir queries.
El problema
// NUNCA hagas esto
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`;
// Un atacante envía: ' OR '1'='1' --
// Resultado: SELECT * FROM users WHERE email = '' OR '1'='1' --'
// → Devuelve TODOS los usuarios
La solución: Queries parametrizadas siempre
// Con un ORM (Prisma) - seguro por defecto
const user = await prisma.user.findUnique({
where: { email: req.body.email },
});
// Con query builder (Knex) - parametrizado automáticamente
const user = await knex('users')
.where('email', req.body.email)
.first();
// Con SQL raw cuando es necesario - parámetros explícitos
const [user] = await db.query(
'SELECT * FROM users WHERE email = $1',
[req.body.email]
);
Nuestra regla: Si ves un template literal construyendo una query SQL, es un bug de seguridad. Sin excepciones. Nuestro linter tiene una regla custom que detecta patrones de concatenación en queries y bloquea el commit.
La inyección no se limita a SQL. También afecta a:
- NoSQL injection en MongoDB:
{ "username": { "$gt": "" } }devuelve todos los documentos - LDAP injection en sistemas de autenticación corporativa
- OS command injection cuando se ejecutan comandos del sistema con input del usuario
La defensa siempre es la misma: nunca confíes en el input del usuario, usa abstracciones que parametricen automáticamente.
Cross-Site Scripting (XSS): Más sutil de lo que parece
XSS permite a un atacante inyectar JavaScript en tu aplicación que se ejecuta en el navegador de otros usuarios. Puede robar cookies de sesión, redirigir a páginas de phishing o modificar el contenido de la página.
Tipos de XSS
- Stored XSS: El script malicioso se almacena en tu base de datos (comentarios, perfiles) y se ejecuta cada vez que alguien ve ese contenido.
- Reflected XSS: El script viene en la URL o en un formulario y se refleja en la respuesta del servidor.
- DOM-based XSS: El script se ejecuta enteramente en el cliente, manipulando el DOM sin pasar por el servidor.
Defensas que aplicamos
1. Escapado automático en el framework
React escapa HTML por defecto. Esto es una ventaja enorme, pero tiene excepciones peligrosas:
// Seguro: React escapa el contenido automáticamente
<p>{userInput}</p> // <script>alert('xss')</script> se renderiza como texto
// PELIGROSO: dangerouslySetInnerHTML bypasea el escapado
<div dangerouslySetInnerHTML={{ __html: userInput }} />
// Solo usar con contenido sanitizado por una librería como DOMPurify
2. Content Security Policy (CSP)
CSP es una cabecera HTTP que le dice al navegador qué recursos puede cargar. Es la defensa más poderosa contra XSS porque incluso si un atacante logra inyectar un script, el navegador lo bloquea:
// Middleware de CSP (Express/Fastify)
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"script-src 'self' 'nonce-${generateNonce()}'",
"style-src 'self' 'unsafe-inline'", // Necesario para CSS-in-JS
"img-src 'self' data: https://cdn.example.com",
"connect-src 'self' https://api.example.com",
"frame-ancestors 'none'", // Previene clickjacking
"base-uri 'self'",
"form-action 'self'",
].join('; '));
next();
});
3. Sanitización de HTML cuando es necesario
Cuando necesitas renderizar HTML del usuario (editores WYSIWYG, markdown), usa una librería de sanitización:
import DOMPurify from 'isomorphic-dompurify';
// Sanitiza HTML eliminando scripts, event handlers y atributos peligrosos
const cleanHtml = DOMPurify.sanitize(userHtml, {
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'h2', 'h3'],
ALLOWED_ATTR: ['href', 'target'],
ALLOW_DATA_ATTR: false,
});
CSRF: Cross-Site Request Forgery
CSRF engaña al navegador del usuario para que envíe peticiones no deseadas a tu aplicación, aprovechando que el navegador incluye automáticamente las cookies de sesión.
Ejemplo de ataque
Un usuario está logueado en tu app bancaria. Visita una página maliciosa que contiene:
<!-- El navegador envía esta petición CON las cookies de sesión del usuario -->
<img src="https://bank.example.com/api/transfer?to=attacker&amount=10000" />
Defensas
1. SameSite cookies (defensa principal)
// Configuración de cookies de sesión
res.cookie('session', token, {
httpOnly: true, // No accesible desde JavaScript
secure: true, // Solo HTTPS
sameSite: 'lax', // No se envía en peticiones cross-origin
maxAge: 3600000, // 1 hora
path: '/',
domain: '.example.com',
});
2. Token CSRF para formularios
Para formularios que mutan estado, generamos un token CSRF único por sesión:
import { randomBytes } from 'crypto';
// Generar token
function generateCsrfToken(sessionId: string): string {
const token = randomBytes(32).toString('hex');
// Almacenar en la sesión del servidor
sessions.get(sessionId).csrfToken = token;
return token;
}
// Verificar token en cada POST/PUT/PATCH/DELETE
function verifyCsrfToken(req: Request): boolean {
const token = req.headers['x-csrf-token'] || req.body._csrf;
return token === sessions.get(req.sessionId).csrfToken;
}
Autenticación y autorización: Donde más se falla
La autenticación (quién eres) y la autorización (qué puedes hacer) son las áreas con más vulnerabilidades en las aplicaciones que auditamos.
Errores comunes que encontramos
1. Autorización solo en el frontend
// MAL: El botón está oculto, pero el endpoint no verifica permisos
// Frontend
{user.role === 'admin' && <button onClick={deleteUser}>Eliminar</button>}
// Backend - SIN verificación
app.delete('/api/users/:id', async (req, res) => {
await db.user.delete({ where: { id: req.params.id } });
// Cualquier usuario autenticado puede borrar usuarios
});
// BIEN: Verificación en el backend SIEMPRE
app.delete('/api/users/:id', authenticate, authorize('admin'), async (req, res) => {
await db.user.delete({ where: { id: req.params.id } });
});
2. IDOR: Insecure Direct Object References
Uno de los fallos más comunes y más fáciles de explotar. El usuario cambia un ID en la URL y accede a datos de otro usuario:
// MAL: No verifica que el pedido pertenece al usuario
app.get('/api/orders/:id', authenticate, async (req, res) => {
const order = await db.order.findUnique({
where: { id: req.params.id },
});
res.json(order); // Devuelve el pedido de CUALQUIER usuario
});
// BIEN: Filtrar siempre por el usuario autenticado
app.get('/api/orders/:id', authenticate, async (req, res) => {
const order = await db.order.findFirst({
where: {
id: req.params.id,
userId: req.user.id, // Solo pedidos del usuario actual
},
});
if (!order) return res.status(404).json({ error: 'Not found' });
res.json(order);
});
3. Almacenamiento inseguro de contraseñas
Parece obvio en 2025, pero seguimos encontrando aplicaciones que almacenan contraseñas en MD5 o SHA-256 sin salt:
// MAL: Hash rápido sin salt
const hash = crypto.createHash('sha256').update(password).digest('hex');
// BIEN: bcrypt con coste adecuado
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12; // ~250ms en hardware moderno
const hash = await bcrypt.hash(password, SALT_ROUNDS);
const isValid = await bcrypt.compare(inputPassword, storedHash);
Si usas un hash que se puede calcular en microsegundos, un atacante con una GPU moderna puede probar mil millones de combinaciones por segundo. bcrypt con coste 12 limita eso a unos pocos miles por segundo.
Cabeceras de seguridad HTTP
Las cabeceras HTTP son una capa de defensa que no requiere cambios en tu código de aplicación. Estas son las que configuramos en todos los proyectos:
// Middleware de cabeceras de seguridad
function securityHeaders(req: Request, res: Response, next: NextFunction) {
// Previene que el navegador adivine el Content-Type
res.setHeader('X-Content-Type-Options', 'nosniff');
// Activa el filtro XSS del navegador (legacy, pero no hace daño)
res.setHeader('X-XSS-Protection', '1; mode=block');
// Previene que tu sitio se cargue en un iframe (clickjacking)
res.setHeader('X-Frame-Options', 'DENY');
// Controla qué información se envía en el Referer
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Fuerza HTTPS durante 1 año
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
// Controla qué APIs del navegador puede usar tu sitio
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
next();
}
Puedes verificar las cabeceras de tu sitio en securityheaders.com. Apuntamos a una calificación A+ en todos los proyectos.
Validación de entrada: La primera línea de defensa
Toda entrada del usuario debe validarse en el servidor. Siempre. La validación del frontend es para UX; la del backend es para seguridad.
import { z } from 'zod';
// Schema de validación con Zod
const createUserSchema = z.object({
name: z.string()
.min(2, 'El nombre debe tener al menos 2 caracteres')
.max(100, 'El nombre no puede exceder 100 caracteres')
.regex(/^[a-zA-ZÀ-ÿ\s'-]+$/, 'El nombre contiene caracteres no permitidos'),
email: z.string()
.email('Email inválido')
.toLowerCase()
.max(255),
password: z.string()
.min(8, 'La contraseña debe tener al menos 8 caracteres')
.regex(/[A-Z]/, 'Debe contener al menos una mayúscula')
.regex(/[0-9]/, 'Debe contener al menos un número')
.regex(/[^A-Za-z0-9]/, 'Debe contener al menos un carácter especial'),
age: z.number()
.int()
.min(18, 'Debes ser mayor de edad')
.max(120, 'Edad no válida'),
});
// Middleware de validación reutilizable
function validate(schema: z.ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
details: result.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
})),
},
});
}
req.body = result.data; // Usa el dato parseado y tipado
next();
};
}
app.post('/api/users', validate(createUserSchema), createUser);
Principios clave:
- Valida tipos, longitud, formato y rango de cada campo
- Usa whitelisting (acepta solo lo esperado) en lugar de blacklisting (rechaza lo peligroso)
- Sanitiza después de validar: trim strings, normaliza emails, escapa caracteres especiales
- Valida en el punto de entrada (controller/route handler), no en las capas internas
Gestión de secretos: Fuera del código
Los secretos (API keys, contraseñas de bases de datos, tokens de terceros) nunca deben estar en el código fuente. Parece obvio, pero GitHub encuentra más de 10 millones de secretos expuestos en repositorios cada año.
Nuestro stack de gestión de secretos
Desarrollo local:
- Archivos
.enven.gitignore(nunca commitear) .env.examplecon las variables necesarias pero sin valores reales
CI/CD:
- GitHub Secrets para variables de entorno en pipelines
- OIDC federation con AWS para evitar API keys de larga duración
Producción:
- AWS Secrets Manager para secretos que rotan automáticamente (contraseñas de BD, API keys)
- AWS SSM Parameter Store para configuración no sensible (feature flags, URLs de servicios)
// Obtener secretos en runtime desde Secrets Manager
import { SecretsManager } from '@aws-sdk/client-secrets-manager';
const sm = new SecretsManager({});
async function getDbCredentials(): Promise<DbCredentials> {
const secret = await sm.getSecretValue({
SecretId: 'production/database/credentials',
});
return JSON.parse(secret.SecretString!);
}
// Rotación automática cada 30 días
// Configurado en Terraform, no en el código
Pre-commit hooks para prevenir leaks
Usamos git-secrets o gitleaks como pre-commit hook para detectar secretos antes de que lleguen al repositorio:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
Si el hook detecta algo que parece un secreto (patrones de API keys, tokens con alta entropía), bloquea el commit con un mensaje claro.
Escaneo de dependencias
Tu aplicación es tan segura como su dependencia más débil. Un paquete npm con una vulnerabilidad conocida puede comprometer todo tu sistema.
Herramientas que usamos
- Dependabot (GitHub): PR automáticos cuando hay actualizaciones de seguridad
- npm audit en CI: Falla el build si hay vulnerabilidades críticas o altas
- Snyk para análisis más profundo en proyectos enterprise
# En el pipeline CI
- name: Security audit
run: |
npm audit --audit-level=high
# Falla si hay vulnerabilidades high o critical
Política de actualización:
- Vulnerabilidades critical: Patch en menos de 24 horas
- Vulnerabilidades high: Patch en menos de 72 horas
- Vulnerabilidades medium: Incluir en el próximo sprint
- Vulnerabilidades low: Evaluar en la revisión trimestral
Cifrado: En tránsito y en reposo
En tránsito
- TLS 1.3 obligatorio para todas las comunicaciones. TLS 1.2 como mínimo para compatibilidad.
- Certificados gestionados por AWS Certificate Manager (renovación automática, coste cero).
- HSTS con preload para forzar HTTPS incluso en la primera visita.
En reposo
- RDS: Cifrado con AWS KMS activado por defecto en todas las instancias.
- S3: Server-side encryption (SSE-S3 o SSE-KMS) en todos los buckets.
- EBS: Cifrado activado a nivel de cuenta para todos los volúmenes nuevos.
// Cifrado adicional a nivel de aplicación para datos sensibles
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
function encrypt(plaintext: string, key: Buffer): EncryptedData {
const iv = randomBytes(16);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return {
ciphertext: encrypted.toString('base64'),
iv: iv.toString('base64'),
authTag: authTag.toString('base64'),
};
}
function decrypt(data: EncryptedData, key: Buffer): string {
const decipher = createDecipheriv(
ALGORITHM,
key,
Buffer.from(data.iv, 'base64'),
);
decipher.setAuthTag(Buffer.from(data.authTag, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(data.ciphertext, 'base64')),
decipher.final(),
]).toString('utf8');
}
AES-256-GCM proporciona tanto confidencialidad como integridad. El auth tag garantiza que los datos no han sido manipulados. Nunca uses AES-ECB o AES-CBC sin HMAC.
Nuestra checklist de seguridad
Antes de cada release a producción, revisamos esta lista. No es negociable:
Autenticación y sesiones
- [ ] Contraseñas hasheadas con bcrypt (coste >= 12) o Argon2
- [ ] Tokens de sesión con entropía suficiente (>= 128 bits)
- [ ] Cookies con httpOnly, secure, sameSite
- [ ] Rate limiting en endpoints de login (máximo 5 intentos/minuto)
- [ ] Bloqueo temporal de cuenta tras 10 intentos fallidos
Autorización
- [ ] Verificación de permisos en CADA endpoint del backend
- [ ] Tests automatizados de acceso (usuario A no puede ver datos de usuario B)
- [ ] Principio de mínimo privilegio en roles IAM y de aplicación
Datos
- [ ] Validación de entrada con schema (Zod/Joi) en todos los endpoints
- [ ] Queries parametrizadas (nunca concatenación de strings)
- [ ] Sanitización de HTML en campos de texto enriquecido
- [ ] Cifrado en tránsito (TLS) y en reposo (KMS)
Infraestructura
- [ ] Cabeceras de seguridad configuradas (CSP, HSTS, X-Frame-Options)
- [ ] Secretos en Secrets Manager (nunca en código o variables de entorno)
- [ ] Dependencias auditadas sin vulnerabilidades high/critical
- [ ] Pre-commit hooks para detectar secretos
- [ ] Logs de seguridad activados y monitorizados
Respuesta a incidentes
- [ ] Plan documentado de respuesta a incidentes
- [ ] Contactos de escalación definidos
- [ ] Procedimiento de rotación de secretos comprometidos
- [ ] Backups verificados y proceso de restauración probado
La seguridad es un proceso, no un producto
No existe la aplicación 100% segura. Pero existe la diferencia entre un equipo que piensa en seguridad desde el diseño y uno que la añade como un checkbox al final. La primera mentalidad previene el 95% de los ataques comunes. La segunda invita a la brecha de datos.
Lo más importante que hemos aprendido: la seguridad es responsabilidad de todo el equipo, no solo del experto en seguridad. Cada developer que valida input correctamente, cada PR review que detecta una query sin parametrizar, cada test que verifica permisos de acceso — todo suma.
¿Quieres auditar la seguridad de tu aplicación o integrar prácticas de seguridad en tu proceso de desarrollo? Hablemos y te ayudamos a proteger tu producto.