Seguridad web: los fundamentos que no puedes saltarte
Las vulnerabilidades más frecuentes en aplicaciones web, explicadas con ejemplos de código reales y soluciones concretas que deberías conocer.

La seguridad no es un sprint de última hora
La mayoría de las vulnerabilidades de seguridad no son ataques sofisticados. Son errores básicos cometidos bajo presión de plazos: validaciones de entrada que faltan, secretos filtrados, comprobaciones de autorización olvidadas. Esta guía repasa los fundamentos prácticos que todo desarrollador debería interiorizar.
Vulnerabilidad 1: inyección SQL
Sigue siendo la vulnerabilidad crítica más común en las aplicaciones web, a pesar de que el problema se resolvió hace décadas.
// ❌ Never concatenate user input into SQL
async function getUser(username: string) {
// If username = "admin' OR '1'='1" — returns all users
const query = `SELECT * FROM users WHERE username = '${username}'`;
return db.query(query);
}
// ✅ Always use parameterized queries
async function getUser(username: string) {
return db.query("SELECT * FROM users WHERE username = $1", [username]);
}
// ✅ Or use a query builder / ORM
async function getUser(username: string) {
return prisma.user.findUnique({ where: { username } });
}Las consultas parametrizadas no son negociables. No existe una forma segura de concatenar la entrada del usuario directamente en SQL.
Vulnerabilidad 2: Cross-Site Scripting (XSS)
El XSS permite a los atacantes inyectar scripts en tus páginas que se ejecutan en el navegador de otros usuarios, robando sesiones, exfiltrando datos o secuestrando cuentas.
// ❌ Never render raw HTML from untrusted sources
function Comment({ content }: { content: string }) {
return <div dangerouslySetInnerHTML={{ __html: content }} />;
}
// If content = "<script>document.cookie</script>" — you've been XSS'd
// ✅ React escapes by default — use it
function Comment({ content }: { content: string }) {
return <div>{content}</div>; // Safe — React escapes HTML entities
}
// ✅ If you must render HTML, sanitize first
import DOMPurify from "dompurify";
function RichContent({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["p", "b", "i", "em", "strong", "a"],
ALLOWED_ATTR: ["href", "title"],
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}Configura además una Content Security Policy estricta para limitar qué scripts pueden ejecutarse incluso si llega a producirse un XSS.
Vulnerabilidad 3: control de acceso deficiente
Los errores de autorización son sutiles porque el código normalmente funciona: simplemente funciona para los usuarios equivocados.
// ❌ Missing authorization check — any authenticated user can delete any comment
app.delete("/api/comments/:id", authenticate, async (req, res) => {
await db.comment.delete({ where: { id: req.params.id } });
res.json({ success: true });
});
// ✅ Verify ownership before mutation
app.delete("/api/comments/:id", authenticate, async (req, res) => {
const comment = await db.comment.findUnique({
where: { id: req.params.id },
});
if (!comment) {
return res.status(404).json({ error: "Not found" });
}
if (comment.authorId !== req.user.id) {
return res.status(403).json({ error: "Forbidden" });
}
await db.comment.delete({ where: { id: req.params.id } });
res.json({ success: true });
});En aplicaciones complejas, utiliza una librería de autorización dedicada (Casbin, Permit.io o una implementación de RBAC a medida) en lugar de comprobaciones if improvisadas y repartidas por todo el código.
Vulnerabilidad 4: gestión insegura de secretos
Los secretos incrustados en el código terminan filtrándose tarde o temprano. Cualquier clave de API que se llegue a subir a un repositorio debe considerarse comprometida.
# ❌ Hard-coded secrets
DATABASE_URL="postgresql://admin:password123@prod-db/app"
STRIPE_SECRET_KEY="sk_live_..."
# ✅ Environment variables — never committed
echo ".env.local" >> .gitignore
echo ".env*.local" >> .gitignore// ✅ Validate secrets at startup, fail fast if missing
const requiredEnvVars = [
"DATABASE_URL",
"JWT_SECRET",
"STRIPE_SECRET_KEY",
] as const;
for (const key of requiredEnvVars) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
}Usa un gestor de secretos (AWS Secrets Manager, Doppler, HashiCorp Vault) en producción. Rota cualquier clave que haya llegado a subirse a un repositorio, aunque después se revirtiera ese commit.
Vulnerabilidad 5: autenticación débil
Secretos de JWT con valores por defecto, sesiones que nunca caducan, contraseñas guardadas en texto plano: estos son los patrones de autenticación que acaban provocando brechas de seguridad.
// ❌ Weak JWT configuration
const token = jwt.sign({ userId }, "secret"); // No expiry, weak secret
// ✅ Proper JWT configuration
const token = jwt.sign(
{ userId, iat: Math.floor(Date.now() / 1000) },
process.env.JWT_SECRET!, // Strong, random secret from env
{
expiresIn: "15m", // Short-lived access tokens
issuer: "myapp.com",
audience: "api.myapp.com",
},
);
// ✅ Password hashing — always use bcrypt or argon2
import { hash, verify } from "argon2";
async function hashPassword(password: string): Promise<string> {
return hash(password, {
type: argon2.argon2id,
memoryCost: 19456,
parallelism: 1,
timeCost: 2,
});
}
async function verifyPassword(
hash: string,
password: string,
): Promise<boolean> {
return verify(hash, password);
}Lista de verificación de cabeceras de seguridad
// next.config.ts — minimum security headers for any web app
const securityHeaders = [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'nonce-{NONCE}'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"connect-src 'self' https://api.myapp.com",
].join("; "),
},
];Antes de lanzar tu aplicación, pásala por securityheaders.com y por OWASP ZAP. Añade estas comprobaciones a tu pipeline de CI.
La seguridad no consiste en ser paranoico. Consiste en entender cómo maneja tu aplicación los datos no confiables y asegurarte de que cada camino del código sea intencional.


