Aron Robertsson Diseñador y desarrollador web
Pozuelo de Alarcón — 2026

← Volver al blog
Seguridad · 8 min de lectura

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.
  • .env y .env*.local está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.

¿Reviso la seguridad de tu web?

Antes del launch te hago una revisión de seguridad y te digo exactamente qué arreglar. Directo y sin tecnicismos.