Skip to content

CORS y seguridad ​

Una API pública en internet recibe ataques automatizados desde el primer día. En esta lección configurarás CORS correctamente y aplicarás las defensas esenciales: helmet, rate limiting, límites de tamaño, validación y manejo seguro de errores. Para profundizar, revisa el curso de Seguridad web.

Concepto: ¿qué es CORS? ​

Por seguridad, el navegador aplica la política del mismo origen: una página en https://mitienda.com no puede leer respuestas de https://api.otro.com a menos que esa API lo permita explícitamente. Un origen = protocolo + dominio + puerto.

PáginaAPI¿Mismo origen?
http://localhost:5173http://localhost:3000❌ (puerto distinto)
https://app.comhttps://app.com/api✅
https://app.comhttps://api.app.com❌ (subdominio distinto)

CORS (Cross-Origin Resource Sharing) es el mecanismo con el que tu API dice "este origen sí puede leerme", mediante headers como Access-Control-Allow-Origin.

Para peticiones "no simples" (con Content-Type: application/json, PUT, DELETE, cookies...) el navegador primero envía una petición preflight OPTIONS para preguntar si está permitido.

Importante

CORS lo aplica el navegador. Postman, curl u otro servidor ignoran CORS. Por eso CORS no es una medida de autenticación: protege a los usuarios del navegador, no a tu API.

El error que verás ​

txt
Access to fetch at 'http://localhost:3000/api/productos' from origin 'http://localhost:5173'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present.

Configurar CORS con el paquete cors ​

bash
npm i cors helmet express-rate-limit
js
// src/config/cors.js
import cors from 'cors'

// Lista blanca desde variable de entorno: "https://mitienda.com,https://admin.mitienda.com"
const permitidos = (process.env.CORS_ORIGIN ?? 'http://localhost:5173')
  .split(',')
  .map((o) => o.trim())

export const corsMiddleware = cors({
  origin(origin, callback) {
    // Peticiones sin origen (curl, apps móviles, server-to-server) se permiten
    if (!origin || permitidos.includes(origin)) return callback(null, true)
    callback(new Error(`Origen no permitido por CORS: ${origin}`))
  },
  credentials: true,                       // permite cookies (requiere origen explícito)
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 600                              // cachea el preflight 10 minutos
})

Error común

Usar origin: '*' junto con credentials: true. El navegador lo rechaza: con cookies debes indicar orígenes concretos. Y * en una API privada es mala idea de todos modos.

helmet: headers de seguridad ​

helmet agrega de una vez una docena de headers HTTP que protegen contra ataques comunes:

HeaderProtege contra
Content-Security-PolicyXSS: limita de dónde se cargan scripts
Strict-Transport-SecurityFuerza HTTPS
X-Content-Type-Options: nosniffQue el navegador "adivine" tipos de archivo
X-Frame-Options / frame-ancestorsClickjacking (meter tu sitio en un iframe)
Referrer-PolicyFuga de URLs a otros sitios

Además quita X-Powered-By: Express, que revela tu tecnología.

js
import helmet from 'helmet'

app.use(helmet())

// Si sirves un frontend y cargas recursos externos, ajusta la CSP:
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        imgSrc: ["'self'", 'data:', 'https://res.cloudinary.com'],
        scriptSrc: ["'self'"]
      }
    },
    crossOriginResourcePolicy: { policy: 'cross-origin' } // si otros dominios muestran tus /uploads
  })
)

Rate limiting ​

Limita cuántas peticiones puede hacer una IP en una ventana de tiempo. Frena ataques de fuerza bruta al login, scraping y abuso.

js
// src/config/rateLimit.js
import { rateLimit } from 'express-rate-limit'

// General: 300 peticiones cada 15 minutos por IP
export const limiteGeneral = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 300,
  standardHeaders: 'draft-8', // headers RateLimit estándar
  legacyHeaders: false,
  message: { error: 'Demasiadas peticiones, intenta más tarde' }
})

// Estricto para login/registro: 5 intentos cada 15 min
export const limiteAuth = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 5,
  skipSuccessfulRequests: true, // sólo cuentan los intentos fallidos
  message: { error: 'Demasiados intentos. Espera 15 minutos.' }
})
js
app.use('/api', limiteGeneral)
app.use('/api/auth/login', limiteAuth)

Detrás de un proxy

En Render, Railway, Nginx o Cloudflare, la IP real viene en el header X-Forwarded-For. Activa app.set('trust proxy', 1) o todas las peticiones parecerán venir de la misma IP (la del proxy).

Con varios servidores (o PM2 en modo clúster), el contador en memoria no se comparte: usa un almacén como Redis (rate-limit-redis).

App segura completa ​

js
import express from 'express'
import helmet from 'helmet'
import cookieParser from 'cookie-parser'
import { corsMiddleware } from './config/cors.js'
import { limiteGeneral, limiteAuth } from './config/rateLimit.js'
import authRouter from './routes/auth.routes.js'
import productosRouter from './routes/productos.routes.js'

const app = express()

// 1. Confiar en el proxy (Render, Nginx...) para obtener la IP real
app.set('trust proxy', 1)

// 2. Headers de seguridad y CORS primero
app.use(helmet())
app.use(corsMiddleware)

// 3. Límites
app.use('/api', limiteGeneral)
app.use('/api/auth/login', limiteAuth)
app.use('/api/auth/registro', limiteAuth)

// 4. Parsers con límite de tamaño (evita bodies gigantes)
app.use(express.json({ limit: '100kb' }))
app.use(express.urlencoded({ extended: false, limit: '100kb' }))
app.use(cookieParser())

// 5. Rutas
app.get('/api/salud', (req, res) => res.json({ ok: true }))
app.use('/api/auth', authRouter)
app.use('/api/productos', productosRouter)

// 6. 404 y errores al final
app.use((req, res) => res.status(404).json({ error: 'No encontrado' }))

// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
  if (err.message?.startsWith('Origen no permitido')) {
    return res.status(403).json({ error: 'Origen no permitido' })
  }
  const status = err.status ?? 500
  if (status >= 500) console.error(err)
  res.status(status).json({
    error: status >= 500 && process.env.NODE_ENV === 'production'
      ? 'Error interno del servidor' // nunca detalles internos en producción
      : err.message
  })
})

export default app
env
NODE_ENV=development
CORS_ORIGIN=http://localhost:5173,http://localhost:4173

Checklist de seguridad para APIs ​

ÁreaMedida
EntradaValidar todo con zod (tipos, longitudes, formatos)
SQL/NoSQLConsultas parametrizadas; nunca concatenar
Contraseñasbcrypt/argon2, nunca texto plano
SesionesCookies httpOnly + secure + sameSite; JWT con expiración
AutorizaciónVerifica que el recurso pertenezca al usuario (no sólo que esté autenticado)
Headershelmet()
AbusoRate limiting, más estricto en login
ErroresSin stack traces ni mensajes de BD en producción
SecretosVariables de entorno, nunca en Git
Dependenciasnpm audit y actualizaciones regulares
TransporteHTTPS siempre (Let's Encrypt en tu VPS)
LogsRegistra intentos fallidos, pero nunca contraseñas ni tokens

Autorización por recurso (IDOR) ​

El error de seguridad más común en APIs: verificar que el usuario está autenticado, pero no que el recurso le pertenece.

js
// ❌ Cualquier usuario autenticado puede ver el pedido de otro cambiando el id
router.get('/pedidos/:id', requiereAuth, async (req, res) => {
  res.json(await prisma.pedido.findUnique({ where: { id: Number(req.params.id) } }))
})

// ✅ Filtra también por el dueño
router.get('/pedidos/:id', requiereAuth, async (req, res) => {
  const pedido = await prisma.pedido.findFirst({
    where: { id: Number(req.params.id), usuarioId: req.usuario.id }
  })
  if (!pedido) return res.status(404).json({ error: 'Pedido no encontrado' })
  res.json(pedido)
})

Buena práctica

Revisa tu API con la lista OWASP API Security Top 10. Las tres primeras (autorización rota por objeto, autenticación rota y exposición excesiva de datos) causan la mayoría de las filtraciones reales.

Ejercicios ​

  1. Configura CORS para que en desarrollo acepte cualquier localhost (cualquier puerto) y en producción sólo https://mitienda.com.
  2. Crea un limitador para POST /api/contacto que permita 3 mensajes por hora por IP y responda con un mensaje en español.
Ver solución
js
// 1
import cors from 'cors'

const esProd = process.env.NODE_ENV === 'production'

export const corsMiddleware = cors({
  origin(origin, cb) {
    if (!origin) return cb(null, true)
    const permitido = esProd
      ? origin === 'https://mitienda.com'
      : /^http:\/\/localhost:\d+$/.test(origin)
    cb(permitido ? null : new Error(`Origen no permitido por CORS: ${origin}`), permitido)
  },
  credentials: true
})
js
// 2
import { rateLimit } from 'express-rate-limit'

const limiteContacto = rateLimit({
  windowMs: 60 * 60 * 1000,
  limit: 3,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  message: { error: 'Ya enviaste varios mensajes. Intenta de nuevo en una hora.' }
})

app.post('/api/contacto', limiteContacto, (req, res) => {
  // validar y enviar email...
  res.status(202).json({ ok: true })
})

Resumen ​

  • CORS lo aplica el navegador; configura una lista blanca de orígenes y credentials: true si usas cookies.
  • helmet() agrega headers de seguridad con una línea.
  • express-rate-limit frena fuerza bruta; recuerda trust proxy detrás de un proxy.
  • Limita el tamaño del body, valida todo, autoriza por recurso y no expongas errores internos.

Academia Web — aprende, copia y construye.