Guía de Seguridad para Desarrolladores: 20 Prácticas Esenciales
La seguridad no es responsabilidad solo del equipo de seguridad. Es responsabilidad de cada desarrollador que escribe código.
Esta guía te da 20 prácticas concretas, con código de ejemplo, que puedes implementar hoy mismo.
1. Nunca Confíes en Input del Usuario
Regla de oro: Toda entrada es maliciosa hasta que se demuestre lo contrario.
Ejemplo Vulnerable:
// ❌ VULNERABLE
app.get('/search', (req, res) => {
const query = `SELECT * FROM products WHERE name LIKE '%${req.query.term}%'`
const results = db.query(query)
res.json(results)
})
// Ataque: /search?term='; DROP TABLE products; --
Ejemplo Seguro:
// ✅ SEGURO - Query parametrizada
app.get('/search', async (req, res) => {
const term = req.query.term
// Validación
if (!term || typeof term !== 'string' || term.length > 100) {
return res.status(400).json({ error: 'Término de búsqueda inválido' })
}
// Sanitización
const sanitized = term.trim()
// Query parametrizada (previene SQL injection)
const results = await db.query(
'SELECT * FROM products WHERE name LIKE ?',
[`%${sanitized}%`]
)
res.json(results)
})
Herramientas:
- Validación:
zod,joi,yup - Sanitización:
validator.js,DOMPurify(frontend)
2. Usa Queries Parametrizadas SIEMPRE
❌ String Concatenation:
// Vulnerable a SQL Injection
const query = `SELECT * FROM users WHERE id = ${userId}`
✅ Prepared Statements:
// Node.js con diferentes ORMs/drivers
// Con mysql2
const [rows] = await connection.execute(
'SELECT * FROM users WHERE id = ?',
[userId]
)
// Con PostgreSQL (pg)
const result = await client.query(
'SELECT * FROM users WHERE id = $1',
[userId]
)
// Con Prisma (ORM)
const user = await prisma.user.findUnique({
where: { id: userId } // Automáticamente parametrizado
})
// Con TypeORM
const user = await userRepository.findOne({
where: { id: userId } // Automáticamente parametrizado
})
3. Hash de Contraseñas Correctamente
❌ NUNCA hagas esto:
// Texto plano
user.password = req.body.password
// MD5/SHA1 (obsoletos y rápidos de crackear)
user.password = crypto.createHash('md5').update(password).digest('hex')
// SHA256 sin salt
user.password = crypto.createHash('sha256').update(password).digest('hex')
✅ Usa bcrypt o Argon2:
import bcrypt from 'bcrypt'
// Al crear/actualizar contraseña
const saltRounds = 12 // Más rounds = más seguro pero más lento
const hashedPassword = await bcrypt.hash(password, saltRounds)
// Al verificar contraseña
const isValid = await bcrypt.compare(plainPassword, hashedPassword)
// Con Argon2 (más moderno)
import argon2 from 'argon2'
const hashedPassword = await argon2.hash(password)
const isValid = await argon2.verify(hashedPassword, plainPassword)
Por qué bcrypt/Argon2:
- Diseñados para ser lentos (previenen fuerza bruta)
- Salt automático (cada hash es único)
- Adaptables (puedes aumentar rounds con el tiempo)
4. Implementa Autenticación Robusta
Checklist de Autenticación:
// Login endpoint seguro
app.post('/api/auth/login',
rateLimiter, // Rate limiting
async (req, res) => {
const { email, password } = req.body
// 1. Validación de input
if (!email || !password) {
return res.status(400).json({ error: 'Campos requeridos' })
}
// 2. Buscar usuario (usa timing-safe comparison)
const user = await db.users.findByEmail(email)
// 3. Verificar contraseña
if (!user || !(await bcrypt.compare(password, user.password))) {
// Mismo mensaje para ambos casos (no revelar si el email existe)
await logSecurityEvent('login_failed', { email, ip: req.ip })
return res.status(401).json({ error: 'Credenciales inválidas' })
}
// 4. Verificar estado de la cuenta
if (user.isBanned) {
return res.status(403).json({ error: 'Cuenta suspendida' })
}
if (user.requiresEmailVerification && !user.emailVerified) {
return res.status(403).json({ error: 'Verifica tu email primero' })
}
// 5. Generar tokens
const accessToken = generateJWT(user, '15m')
const refreshToken = generateJWT(user, '7d')
// 6. Guardar refresh token en DB (para poder revocar)
await db.refreshTokens.create({
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
})
// 7. Log exitoso
await logSecurityEvent('login_success', {
userId: user.id,
ip: req.ip
})
// 8. Actualizar último login
await db.users.update(user.id, {
lastLoginAt: new Date(),
lastLoginIp: req.ip
})
res.json({ accessToken, refreshToken })
}
)
5. Protege tus JWT
❌ Malas prácticas:
// Secret débil
const secret = 'secret'
// Sin expiración
const token = jwt.sign({ userId: user.id }, secret)
// Datos sensibles en payload
const token = jwt.sign({
userId: user.id,
password: user.password, // ❌ NUNCA
creditCard: user.card // ❌ NUNCA
}, secret)
✅ Buenas prácticas:
import jwt from 'jsonwebtoken'
// Secret fuerte (guardado en variable de entorno)
const JWT_SECRET = process.env.JWT_SECRET // Al menos 32 caracteres random
// Función para generar tokens
function generateJWT(user, expiresIn = '15m') {
const payload = {
sub: user.id, // Subject (user ID)
email: user.email,
role: user.role,
iat: Math.floor(Date.now() / 1000), // Issued at
// NO incluir datos sensibles
}
return jwt.sign(payload, JWT_SECRET, {
expiresIn, // Token expira
issuer: 'mi-app.com', // Quién emitió el token
audience: 'mi-app.com' // Para quién es el token
})
}
// Middleware para verificar tokens
function authenticateJWT(req, res, next) {
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token no proporcionado' })
}
const token = authHeader.substring(7) // Remover "Bearer "
try {
const decoded = jwt.verify(token, JWT_SECRET, {
issuer: 'mi-app.com',
audience: 'mi-app.com'
})
req.user = decoded
next()
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expirado' })
}
return res.status(401).json({ error: 'Token inválido' })
}
}
6. Implementa Control de Acceso en Cada Endpoint
Patrón de Middleware:
// middleware/authorize.js
export function authorize(...allowedRoles) {
return async (req, res, next) => {
// Usuario debe estar autenticado primero
if (!req.user) {
return res.status(401).json({ error: 'No autenticado' })
}
// Verificar rol
if (!allowedRoles.includes(req.user.role)) {
await logSecurityEvent('access_denied', {
userId: req.user.id,
resource: req.path,
requiredRoles: allowedRoles,
userRole: req.user.role
})
return res.status(403).json({ error: 'No autorizado' })
}
next()
}
}
// Uso en rutas
app.get('/api/admin/users',
authenticateJWT,
authorize('admin'), // Solo admins
async (req, res) => {
// Código del endpoint
}
)
app.delete('/api/posts/:postId',
authenticateJWT,
authorize('admin', 'moderator'), // Admins o moderators
async (req, res) => {
// Código del endpoint
}
)
Verificación de Ownership:
// middleware/checkOwnership.js
export function checkOwnership(resourceType) {
return async (req, res, next) => {
const resourceId = req.params[`${resourceType}Id`]
const userId = req.user.id
let resource
switch (resourceType) {
case 'post':
resource = await db.posts.findById(resourceId)
break
case 'comment':
resource = await db.comments.findById(resourceId)
break
// ... más recursos
}
if (!resource) {
return res.status(404).json({ error: 'Recurso no encontrado' })
}
// Verificar ownership o admin
if (resource.userId !== userId && req.user.role !== 'admin') {
return res.status(403).json({ error: 'No puedes modificar este recurso' })
}
req.resource = resource // Pasar recurso al handler
next()
}
}
// Uso
app.patch('/api/posts/:postId',
authenticateJWT,
checkOwnership('post'),
async (req, res) => {
// req.resource ya está verificado como propiedad del usuario
await updatePost(req.resource.id, req.body)
res.json({ success: true })
}
)
7. Rate Limiting en Todos los Endpoints Sensibles
Implementación con express-rate-limit:
import rateLimit from 'express-rate-limit'
import RedisStore from 'rate-limit-redis'
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL)
// Rate limiter para login
const loginLimiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: 'rl:login:'
}),
windowMs: 15 * 60 * 1000, // 15 minutos
max: 5, // 5 intentos
message: 'Demasiados intentos de login. Intenta en 15 minutos.',
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
logSecurityEvent('rate_limit_exceeded', {
endpoint: '/login',
ip: req.ip,
email: req.body.email
})
res.status(429).json({
error: 'Demasiados intentos',
retryAfter: req.rateLimit.resetTime
})
}
})
// Rate limiter para API general
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minuto
max: 100, // 100 requests por minuto
message: 'Demasiadas peticiones'
})
// Rate limiter para operaciones costosas
const heavyOperationLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hora
max: 10, // 10 operaciones por hora
keyGenerator: (req) => {
// Limitar por usuario autenticado, no solo IP
return req.user ? req.user.id : req.ip
}
})
// Aplicar en rutas
app.post('/api/auth/login', loginLimiter, loginHandler)
app.use('/api/', apiLimiter) // Rate limit general
app.post('/api/reports/generate',
authenticateJWT,
heavyOperationLimiter,
generateReportHandler
)
8. Valida y Sanitiza TODOS los Inputs
Con Zod (TypeScript):
import { z } from 'zod'
// Schema de validación
const createUserSchema = z.object({
email: z.string().email('Email inválido').toLowerCase(),
password: z.string()
.min(8, 'Mínimo 8 caracteres')
.regex(/[A-Z]/, 'Debe contener mayúscula')
.regex(/[a-z]/, 'Debe contener minúscula')
.regex(/[0-9]/, 'Debe contener número')
.regex(/[^A-Za-z0-9]/, 'Debe contener carácter especial'),
firstName: z.string()
.min(1, 'Nombre requerido')
.max(50, 'Máximo 50 caracteres')
.regex(/^[a-zA-ZáéíóúÁÉÍÓÚñÑ\s]+$/, 'Solo letras y espacios'),
age: z.number()
.int('Debe ser entero')
.min(18, 'Debes ser mayor de 18')
.max(120, 'Edad inválida'),
website: z.string().url('URL inválida').optional(),
role: z.enum(['user', 'admin', 'moderator']).default('user')
})
// Middleware de validación
function validate(schema: z.ZodSchema) {
return (req, res, next) => {
try {
req.validatedData = schema.parse(req.body)
next()
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'Validación fallida',
details: error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}))
})
}
next(error)
}
}
}
// Uso en rutas
app.post('/api/users',
validate(createUserSchema),
async (req, res) => {
// req.validatedData contiene datos validados y sanitizados
const user = await createUser(req.validatedData)
res.json(user)
}
)
9. Configura Headers de Seguridad
Con Helmet.js:
import helmet from 'helmet'
app.use(helmet({
// Content Security Policy
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "trusted-cdn.com"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "api.miapp.com"],
fontSrc: ["'self'", "fonts.gstatic.com"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"]
}
},
// HSTS (HTTPS obligatorio)
hsts: {
maxAge: 31536000, // 1 año
includeSubDomains: true,
preload: true
},
// Prevenir clickjacking
frameguard: {
action: 'deny'
},
// Prevenir MIME sniffing
noSniff: true,
// Referrer Policy
referrerPolicy: {
policy: 'strict-origin-when-cross-origin'
}
}))
// Headers adicionales
app.use((req, res, next) => {
// Prevenir caching de respuestas sensibles
if (req.path.startsWith('/api/')) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private')
}
// Remover header que revela tecnología
res.removeHeader('X-Powered-By')
next()
})
10. Maneja Secretos Correctamente
❌ NUNCA hagas esto:
// Hardcoded en código
const apiKey = 'sk_live_12345abcde'
// En el código fuente
const dbPassword = 'MyP@ssw0rd123'
// Commiteado en Git
git add .env
✅ Usa variables de entorno:
// .env (NUNCA commitear este archivo)
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
JWT_SECRET=muy-secreto-y-largo-al-menos-32-caracteres
STRIPE_API_KEY=sk_live_51abcdefg...
SENDGRID_API_KEY=SG.abc123...
// .env.example (SÍ commitear este archivo)
DATABASE_URL=
JWT_SECRET=
STRIPE_API_KEY=
SENDGRID_API_KEY=
// .gitignore
.env
.env.local
.env.*.local
// Usar en código
import dotenv from 'dotenv'
dotenv.config()
const stripeKey = process.env.STRIPE_API_KEY
if (!stripeKey) {
throw new Error('STRIPE_API_KEY no configurada')
}
Para producción, usa servicios de gestión de secretos:
- AWS Secrets Manager
- Google Cloud Secret Manager
- HashiCorp Vault
- Azure Key Vault
Checklist Rápida de Seguridad
Antes de cada deployment:
Input & Validation
- Todos los inputs validados con schemas
- Queries parametrizadas (no string concatenation)
- Sanitización de HTML en inputs de texto rico
- Validación de tipos y rangos
Autenticación & Autorización
- Contraseñas hasheadas con bcrypt/Argon2
- JWTs con expiración corta (< 30min)
- Refresh tokens implementados
- Rate limiting en login/signup
- Verificación de permisos en cada endpoint
APIs & Endpoints
- Rate limiting configurado
- CORS configurado correctamente
- Headers de seguridad (helmet)
- No hay información sensible en responses
- Errores no revelan detalles internos
Data Protection
- HTTPS en producción
- Secretos en variables de entorno
- Datos sensibles cifrados en DB
- Backups automáticos configurados
Logging & Monitoring
- Eventos de seguridad registrados
- Logs no contienen datos sensibles
- Monitoreo de actividad sospechosa
- Alertas configuradas
Dependencies
- Dependencias actualizadas
- npm audit sin vulnerabilidades críticas
- Dependencias no utilizadas removidas
Herramientas Esenciales
Para Validación:
- Zod (TypeScript): Schema validation
- Joi (JavaScript): Schema validation
- Validator.js: String validation y sanitización
Para Autenticación:
- Passport.js: Estrategias de auth
- jsonwebtoken: JWT
- bcrypt: Hashing de contraseñas
Para Security Headers:
- Helmet.js: Security headers automáticos
Para Rate Limiting:
- express-rate-limit: Rate limiting simple
- rate-limiter-flexible: Rate limiting avanzado con Redis
Para Auditoría:
- npm audit: Vulnerabilidades en dependencias
- Snyk: Monitoreo continuo de vulnerabilidades
- OWASP Dependency-Check: Análisis de dependencias
Para Testing:
- OWASP ZAP: Pentest automático
- Burp Suite: Análisis de tráfico HTTP
- SQLMap: Testing de SQL injection
Conclusión
La seguridad no es un feature que agregas al final. Es una mentalidad que aplicas desde la primera línea de código.
Implementa estas 20 prácticas y estarás en el top 10% de aplicaciones más seguras.
¿Quieres validar que tu código es realmente seguro? En Pentacode realizamos pentests especializados para desarrolladores, con reportes técnicos y recomendaciones específicas de código. Agenda una consultoría.
Próximos pasos:
- Lee sobre OWASP Top 10
- Revisa vulnerabilidades comunes
- Prepara tu app para un pentest profesional