Cómo proteger tu web de hackers
Aron Robertsson · Junio 2026
Construiste algo. Tardaste semanas. Un hacker puede destruirlo en minutos — y probablemente ni te avisará.
La buena noticia: la mayoría de los ataques a proyectos pequeños se evitan con cinco cosas básicas. Te las explico una a una, con el código exacto para arreglarlas, y al final te dejo un checklist para repasar antes de cada deploy. Los ejemplos son en Next.js, pero la idea sirve para cualquier stack.
1. API keys expuestas
Qué pasa: subes tu código a GitHub con las API keys escritas dentro del propio código.
Consecuencia: hay bots que escanean GitHub en segundos buscando claves. Usan tu key de OpenAI (o la que sea) y te despiertas con una factura de 3.000 $.
Solución: las claves NUNCA van en el código. Van en variables de entorno (Vercel, Railway, Supabase…). En Next.js, lo que empieza por NEXT_PUBLIC_ se ve en el navegador; todo lo demás se queda en el servidor.
# .env.local -> never commit this file to GitHub
OPENAI_API_KEY=sk-xxxxxxxx # private: server only
NEXT_PUBLIC_SITE_URL=https://yoursite.com # public: visible in the browser
// On the server (Route Handler / Server Component):
const key = process.env.OPENAI_API_KEY; // safe, never exposed
// Never read a secret on the client:
// process.env.NEXT_PUBLIC_OPENAI_API_KEY -> would ship to the browser
2. Sin rate limiting
Qué pasa: alguien manda 10.000 peticiones por minuto a tu API.
Consecuencia: agotas la cuota, la factura se dispara y la web se cae para todos los demás.
Solución: limita las peticiones por IP. Con Upstash Rate Limit (o el middleware Edge de Vercel) lo montas en cinco minutos.
// lib/ratelimit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
export const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "10 s"), // 10 req / 10s per IP
});
// app/api/chat/route.ts
import { ratelimit } from "@/lib/ratelimit";
export async function POST(req) {
const ip = req.headers.get("x-forwarded-for") ?? "127.0.0.1";
const { success } = await ratelimit.limit(ip);
if (!success) {
return new Response("Too many requests", { status: 429 });
}
// ...your logic
}
3. El .env subido por error
Qué pasa: commiteas el archivo .env sin querer.
Consecuencia: quedan expuestas las credenciales de tu base de datos. Y aunque borres el archivo, sigue en el historial de Git.
Solución: un .gitignore bien puesto desde el minuto cero. Si ya se subió, sácalo del repo y rota las claves (genera nuevas y borra las viejas).
# .gitignore -> make sure these are in there
.env
.env*.local
# Already pushed a .env by mistake? Remove it from the repo:
git rm --cached .env
git commit -m "Remove .env from repo"
# Then rotate every exposed key: generate new ones, delete the old.
4. Autenticación débil
Qué pasa: tu login no tiene límite de intentos.
Consecuencia: un ataque de fuerza bruta prueba miles de contraseñas hasta entrar. Cuentas comprometidas.
Solución: guarda las contraseñas con hash (bcrypt), limita los intentos por IP y activa 2FA donde puedas.
import bcrypt from "bcryptjs";
// On sign-up: store the HASH, never the raw password
const hash = await bcrypt.hash(password, 12);
// On login: compare
const ok = await bcrypt.compare(password, user.hash);
if (!ok) {
return new Response("Invalid credentials", { status: 401 });
}
// Limit login attempts per IP (same ratelimit as before)
const { success } = await ratelimit.limit("login_" + ip);
if (!success) {
return new Response("Too many attempts", { status: 429 });
}
5. Inputs sin validar
Qué pasa: tus formularios aceptan cualquier cosa sin comprobarla.
Consecuencia: inyección SQL (te roban o vacían la base de datos) y ataques XSS (inyectan scripts en tu web).
Solución: valida TODO lo que entra con zod y usa consultas SQL parametrizadas. Nunca metas datos del usuario directos en una query.
import { z } from "zod";
const schema = z.object({
email: z.string().email(),
message: z.string().min(1).max(1000),
});
const result = schema.safeParse(await req.json());
if (!result.success) {
return new Response("Invalid input", { status: 400 });
}
// SQL: always use parameterized queries (never string concatenation)
// BAD: "SELECT * FROM users WHERE email = '" + email + "'"
// GOOD:
await db.query("SELECT * FROM users WHERE email = $1", [result.data.email]);
Checklist antes del deploy
Repásalo antes de pulsar «deploy». Hazle captura y guárdalo.
- Ninguna API key ni secreto dentro del código.
.envy.env*.localestán en el.gitignore.- Las claves sensibles se usan solo en el servidor (sin
NEXT_PUBLIC_). - Rate limiting en las rutas de API.
- Rate limiting también en el login.
- Contraseñas guardadas con hash (bcrypt), nunca en texto plano.
- 2FA activado donde sea posible.
- Todos los inputs validados (zod) antes de tocar la base de datos.
- Consultas SQL parametrizadas (sin concatenar strings).
- Claves rotadas si alguna estuvo expuesta alguna vez.