Cambiar tema

Autenticación y seguridad de APIs en Next.js: guía completa desde JWT hasta rate limiting

Easton editorial illustration: cache waterfall instrument

El móvil vibra: alerta de factura del proveedor cloud, 7800 dólares.

Me froto los ojos, pensando que leí mal. El mes pasado eran unos 120. Abro el detalle: 18 millones de llamadas a la API. Mi proyecto personal suele recibir unas pocas centenas al día.

Resulta que un crawler encontró un endpoint sin ninguna protección y lo martilleó tres días seguidos. Ahí entendí por qué dicen que la seguridad de la API no es opcional.

Este artículo ordena los tropiezos de estos años y las soluciones que he estudiado. De autenticación JWT a CORS, de rate limiting a validación de entrada: no es teoría vacía, sino código que puedes usar en tu proyecto.

Por qué la seguridad de la API importa tanto

Amenazas habituales en APIs

10.0
Puntuación CVSS
Vulnerabilidad crítica de React Server Components (CVE-2025-55182) en diciembre de 2025; el atacante puede ejecutar código arbitrario

En diciembre del año pasado, React publicó un aviso de seguridad grave, CVE-2025-55182, con puntuación CVSS al máximo: 10.0.

¿Qué significa? Nota perfecta. Con una petición HTTP especial, el atacante ejecuta código arbitrario en tu servidor. Si usas React Server Components y no actualizaste, básicamente estás expuesto.

No es un caso aislado. En marzo salió otra vulnerabilidad de bypass de autorización (CVE-2025-29927), puntuación 9.1. Falsificando una cabecera, el atacante salta tu middleware de autenticación. ¿Creías que con auth ya estabas a salvo? Se lo saltan.

Además de esas vulnerabilidades graves, el día a día trae más:

Crawlers maliciosos y ataques DDoS. Sin rate limiting, una API cae en minutos. Vi un login bombardeado a 3000 peticiones por segundo: fuerza bruta de contraseñas y el servidor caído.

Filtración de datos. Sin control de permisos, el usuario A ve los pedidos del usuario B. Eso en titulares arruina la reputación de la marca.

Ataques de inyección. SQL injection, XSS, command injection… suenan a viejos, pero cada año caen proyectos. «Uso React, escapa solo» — si la API no valida, no sirve de nada.

Particularidades de Next.js API Routes

Las API Routes de Next.js no son un backend tradicional; conviene tener en cuenta:

Serverless primero. En Vercel, cada petición es una función Serverless independiente. Ventaja: escala automática. Inconveniente: sin estado — no puedes usar sesión en memoria; toca JWT o sesión en base de datos.

Mezclado con el frontend. Todo en un repo; las variables de entorno pueden filtrarse al cliente. Vi a alguien poner DATABASE_URL en .env y acabar expuesta en el bundle del frontend, en GitHub.

Límites del edge. Con Edge Runtime, algunas APIs de Node.js no funcionan; hay que reelegir librerías de cifrado y conexión a BD. La estrategia de seguridad también cambia.

En resumen: la API de Next.js es tu backend, pero más ligera, más flexible y también más fácil de romper mal configurada.

Autenticación de API en la práctica

Guía para elegir esquema de autenticación

La pregunta práctica: ¿JWT o sesión?

Al empezar con Next.js también lo dudé. Unos dicen JWT obligatorio, otros sesión más segura. Al final depende del escenario.

JWT encaja cuando:

  • Despliegas en varios servidores (Serverless, edge)
  • Necesitas auth entre dominios (frontend en app.com, API en api.com)
  • No quieres gestionar almacenamiento de sesiones; cuanto más simple, mejor

JWT codifica la info del usuario en un token; el servidor no guarda estado y cada petición lleva el token. Escala horizontalmente muy bien.

Sesión encaja cuando:

  • Necesitas control activo del servidor: expulsar usuarios, cambiar permisos al vuelo
  • Seguridad muy alta: no quieres que el cliente tenga datos de usuario
  • Ya tienes Redis o BD; gestionar sesiones no es problema

La sesión vive en el servidor; el cliente solo tiene un session ID. Invalidar a un usuario es borrar su sesión; con JWT puro no es tan directo.

Mi criterio: proyectos pequeños o personales, JWT por comodidad; empresas con control fino, sesión. No te paralices: elige uno, y si hace falta, cambias después.

Implementación completa con JWT

Supongamos JWT. ¿Cómo en Next.js?

Paso 1: generar y verificar el token

Instala una librería:

npm install jose

¿Por qué no jsonwebtoken? No soporta Edge Runtime. jose sigue estándares web y corre en cualquier entorno.

Crea lib/auth.ts:

import { SignJWT, jwtVerify } from 'jose';

const secret = new TextEncoder().encode(
  process.env.JWT_SECRET || 'your-secret-key-at-least-32-characters'
);

export async function createToken(payload: { userId: string }) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('15m') // Expira en 15 minutos
    .sign(secret);
}

export async function verifyToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret);
    return payload;
  } catch {
    return null;
  }
}

Fíjate en 15m de expiración. Muchos ponen 7 o 30 días por comodidad, pero si filtra el token, el atacante lo usa mucho tiempo. Access token corto + refresh token largo es lo correcto.

Paso 2: guardar el token — no uses localStorage

Punto clave. Muchos tutoriales guardan el token en localStorage; llega un XSS y se lo llevan todo.

Lo correcto: cookie HttpOnly.

Al devolver el token en login:

// app/api/login/route.ts
import { NextResponse } from 'next/server';
import { createToken } from '@/lib/auth';

export async function POST(request: Request) {
  // Validar usuario y contraseña...

  const token = await createToken({ userId: user.id });

  const response = NextResponse.json({ success: true });
  response.cookies.set('token', token, {
    httpOnly: true,    // JS no puede leerla; anti-XSS
    secure: true,      // Solo HTTPS
    sameSite: 'lax',   // Anti-CSRF
    maxAge: 900,       // 15 min, igual que el token
  });

  return response;
}

HttpOnly es la clave: JavaScript no lee esa cookie; XSS no se la lleva.

Paso 3: middleware para proteger la API

¿Cómo exigir login en ciertas rutas? Middleware de Next.js.

Crea middleware.ts:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from './lib/auth';

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('token')?.value;

  if (!token) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { status: 401 }
    );
  }

  const payload = await verifyToken(token);
  if (!payload) {
    return NextResponse.json(
      { error: 'Invalid token' },
      { status: 401 }
    );
  }

  // Verificación OK: pasa info de usuario a la API
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-user-id', payload.userId as string);

  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });
}

export const config = {
  matcher: '/api/protected/:path*',
};

Así, todo /api/protected/* queda protegido. En la API, lee al usuario:

// app/api/protected/profile/route.ts
import { headers } from 'next/headers';

export async function GET() {
  const headersList = await headers();
  const userId = headersList.get('x-user-id');

  // Consultar BD...
}

Paso 4: renovación del token

¿Expira a los 15 minutos y el usuario inicia sesión a cada rato? Entra el refresh token.

Access token corto (15 min), refresh token largo (30 días). Cuando caduca el access, cambias por uno nuevo sin volver a login.

La implementación es algo más larga; hay muchos ejemplos y next-auth ya trae la lógica.

Integración rápida con NextAuth.js

La verdad: lo anterior enseña la idea, pero es trabajo. Para ir rápido, NextAuth.js (ahora Auth.js).

Ventajas out of the box:

  • Protección CSRF automática
  • Firma y cifrado de sesión
  • JWT y sesión en BD
  • Login con Google, GitHub, etc.

Instala:

npm install next-auth

Crea app/api/auth/[...nextauth]/route.ts:

import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';

const handler = NextAuth({
  providers: [
    CredentialsProvider({
      name: 'Credentials',
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" }
      },
      async authorize(credentials) {
        // Validar credenciales...
        if (user) {
          return { id: user.id, email: user.email };
        }
        return null;
      }
    })
  ],
  session: {
    strategy: 'jwt',  // JWT, ideal para Serverless
    maxAge: 30 * 24 * 60 * 60, // 30 días
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.userId = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      session.userId = token.userId;
      return session;
    }
  }
});

export { handler as GET, handler as POST };

En la API, comprueba sesión:

import { getServerSession } from 'next-auth';

export async function GET() {
  const session = await getServerSession();

  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Usuario autenticado...
}

Así de simple. NextAuth gestiona tokens y refresco de sesión.

Configuración CORS en detalle

Qué es CORS y problemas frecuentes

CORS (Cross-Origin Resource Sharing) frustra a muchos. En dev todo va; al desplegar:

Access to fetch at 'https://api.example.com' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present.

En corto: el navegador impide que la web A acceda a recursos de B sin permiso. Desde app.com llamas a api.com; el navegador pregunta a la API si permite ese origen y solo pasa si la API dice que sí.

¿Por qué en desarrollo no falla?

En dev, frontend y API están en localhost:3000: mismo origen. Al desplegar, dominios distintos y entra CORS.

¿Qué es una petición Preflight?

Un POST con cabeceras custom (p. ej. Authorization) suele ir precedido de OPTIONS. Eso es Preflight. Si la API no responde OPTIONS, 404 y CORS roto.

Yo caí: POST listo, OPTIONS olvidado, error CORS hasta encontrarlo.

Tres formas de configurar CORS en Next.js

Método 1: next.config.js global

Cuando todas las APIs comparten los mismos orígenes.

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          { key: 'Access-Control-Allow-Origin', value: 'https://app.example.com' },
          { key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE' },
          { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
        ],
      },
    ];
  },
};

Pros: una vez, global. Contras: poco flexible por ruta.

Método 2: middleware

Para lógica dinámica y centralizada.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // Preflight
  if (request.method === 'OPTIONS') {
    return new NextResponse(null, {
      status: 200,
      headers: {
        'Access-Control-Allow-Origin': 'https://app.example.com',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      },
    });
  }

  const response = NextResponse.next();
  response.headers.set('Access-Control-Allow-Origin', 'https://app.example.com');

  return response;
}

export const config = {
  matcher: '/api/:path*',
};

Tienes el objeto request y puedes decidir por origen.

Método 3: dentro de la API Route

Para una ruta con reglas propias.

// app/api/public/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  const data = { message: 'Hello' };

  return NextResponse.json(data, {
    headers: {
      'Access-Control-Allow-Origin': '*', // API pública
    },
  });
}

export async function OPTIONS() {
  return new NextResponse(null, {
    status: 200,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET',
      'Access-Control-Allow-Headers': 'Content-Type',
    },
  });
}

Cada API necesita OPTIONS o Preflight falla.

Buenas prácticas de seguridad CORS

1. No abuses del comodín

Muchos escriben:

'Access-Control-Allow-Origin': '*'

Cualquier sitio puede llamar tu API. Datos públicos, quizá; datos de usuario o acciones sensibles, exposición total.

Mejor: lista explícita de dominios.

const allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];

const origin = request.headers.get('origin');
if (origin && allowedOrigins.includes(origin)) {
  response.headers.set('Access-Control-Allow-Origin', origin);
}

2. Credenciales con cuidado

Si la API lee cookies (sesión), el frontend:

fetch('https://api.example.com', {
  credentials: 'include',
});

Y el backend:

response.headers.set('Access-Control-Allow-Credentials', 'true');

Pero Access-Control-Allow-Origin no puede ser * con credenciales; el navegador lo rechaza. Dominio concreto obligatorio.

3. Gestiona Preflight

Peticiones con Authorization o Content-Type: application/json suelen disparar Preflight. Responde OPTIONS.

Función reutilizable:

export function corsHeaders(origin?: string) {
  return {
    'Access-Control-Allow-Origin': origin || 'https://app.example.com',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    'Access-Control-Max-Age': '86400', // Cache Preflight 24 h
  };
}

Úsala en cada API.

Rate limiting de API

Por qué hace falta rate limiting

Volviendo al inicio: 18 millones de llamadas. Con límite de 100 por IP y minuto, la factura habría sido decenas de dólares, no 7800.

Rate limiting limita cuántas veces puede llamar un usuario en un intervalo. Simple y muy útil:

Anti-DDoS. ¿Avalancha de peticiones? Lo que pase del umbral se rechaza; el servidor aguanta.

Anti fuerza bruta. Login sin límite: miles de contraseñas por segundo. Con 5 intentos por IP y minuto, la dificultad sube muchísimo.

Protege recursos. BD y APIs de terceros cuestan. El límite evita que un usuario agote todo y mantiene el servicio justo.

Comparativa de soluciones

Opciones habituales en Next.js:

Opción 1: @upstash/ratelimit + Vercel KV

La que más uso. Upstash es Redis Serverless, partner de Vercel, integración sencilla.

Pros:

  • Amigable con Serverless, sin administrar Redis
  • Varios algoritmos: ventana fija, deslizante, token bucket
  • Tier gratis suficiente para proyectos personales

Contras:

  • Tráfico alto, de pago
  • Dependes de un tercero

Opción 2: Redis autogestionado

Si ya tienes Redis o no quieres terceros.

Pros:

  • Control total, sin coste extra del servicio
  • Lógica a medida

Contras:

  • Mantener el servidor
  • En Serverless, configuración más compleja

Opción 3: rate limiting en memoria

Sin Redis, límite simple en RAM.

Pros:

  • Cero dependencias, pocas líneas
  • Dev y proyectos pequeños

Contras:

  • En Serverless cada instancia tiene su memoria; el límite no se comparte
  • Reinicio = datos perdidos

Mi consejo: personal + Serverless, Upstash; empresa con servidores propios, Redis; demo o local, memoria.

Ejemplo práctico

Con Upstash, paso a paso.

Paso 1: instalar y configurar

npm install @upstash/ratelimit @upstash/redis

Crea una BD Redis en Upstash, copia UPSTASH_REDIS_REST_URL y UPSTASH_REDIS_REST_TOKEN a .env:

UPSTASH_REDIS_REST_URL=https://xxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-token

Paso 2: crear el limitador

Crea lib/rate-limit.ts:

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

// Ventana deslizante: máx. 10 peticiones en 10 s
export const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '10 s'),
  analytics: true,
});

slidingWindow(10, '10 s'): 10 peticiones en 10 segundos. Más suave que ventana fija, sin picos en el borde.

Paso 3: usar en la API

// app/api/protected/route.ts
import { NextResponse } from 'next/server';
import { ratelimit } from '@/lib/rate-limit';

export async function GET(request: Request) {
  const ip = request.headers.get('x-forwarded-for') || 'unknown';

  const { success, limit, remaining, reset } = await ratelimit.limit(ip);

  if (!success) {
    return NextResponse.json(
      {
        error: 'Too many requests',
        limit,
        remaining,
        reset: new Date(reset),
      },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': limit.toString(),
          'X-RateLimit-Remaining': remaining.toString(),
          'X-RateLimit-Reset': reset.toString(),
        },
      }
    );
  }

  return NextResponse.json({ data: 'Success' });
}

Aquí limitamos por IP. Con login, usa userId:

const identifier = session?.userId || ip;
const { success } = await ratelimit.limit(identifier);

Usuarios autenticados por usuario; anónimos por IP.

Paso 4: rate limiting global en middleware

¿No repetir en cada ruta? Middleware:

// middleware.ts
import { ratelimit } from '@/lib/rate-limit';

export async function middleware(request: NextRequest) {
  const ip = request.ip || 'unknown';
  const { success } = await ratelimit.limit(ip);

  if (!success) {
    return NextResponse.json(
      { error: 'Too many requests' },
      { status: 429 }
    );
  }

  return NextResponse.next();
}

export const config = {
  matcher: '/api/:path*',
};

Todas las APIs protegidas de golpe.

Validación de entrada y defensa

Por qué la validación es la primera línea

«Nunca confíes en la entrada del usuario» — regla de oro en seguridad.

¿Validación en el formulario del frontend? Inútil. DevTools, cambias el JS y listo. La defensa real está en el servidor.

SQL injection. Entrada '; DROP TABLE users; -- concatenada en SQL y adiós BD. ORM ayuda, pero SQL crudo sigue existiendo.

XSS. El usuario envía <script>alert('hacked')</script>, lo guardas y otro usuario ejecuta el script y pierde cookies. React escapa, pero con dangerouslySetInnerHTML caes igual.

DoS. JSON de 10 MB revienta la función Serverless. Cadena enorme y regex que nunca termina.

La validación bloquea la mayoría de ataques tontos. Sin ella, el resto es un colador.

Validación con tipos usando Zod

Zod es mi favorita: TypeScript nativo, encaja con el sistema de tipos.

Instala:

npm install zod

Uso básico

Define un schema:

import { z } from 'zod';

const userSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  age: z.number().int().min(18).max(120),
});

En la API:

// app/api/register/route.ts
import { NextResponse } from 'next/server';
import { userSchema } from '@/lib/schemas';

export async function POST(request: Request) {
  const body = await request.json();

  const result = userSchema.safeParse(body);

  if (!result.success) {
    return NextResponse.json(
      {
        error: 'Validation failed',
        details: result.error.format(),
      },
      { status: 400 }
    );
  }

  const { email, password, age } = result.data;

  // Continuar...
}

Usa safeParse, no lanza excepción. parse sí y necesitas try-catch.

¿Por qué mejor que validar a mano?

A mano:

if (!body.email || typeof body.email !== 'string') {
  return error;
}
if (!body.email.includes('@')) {
  return error;
}
// Y así hasta el infinito...

Con Zod:

z.string().email()

Una línea e inferencia de tipos automática.

Esquema completo de validación

No solo tipos: también reglas de negocio y bordes.

1. Validar body

const postSchema = z.object({
  title: z.string().min(1).max(100),
  content: z.string().max(10000),
  tags: z.array(z.string()).max(10),
  publishedAt: z.string().datetime().optional(),
});

2. Validar query

// app/api/posts/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);

  const querySchema = z.object({
    page: z.coerce.number().int().min(1).default(1),
    limit: z.coerce.number().int().min(1).max(100).default(20),
    sort: z.enum(['asc', 'desc']).default('desc'),
  });

  const params = querySchema.parse({
    page: searchParams.get('page'),
    limit: searchParams.get('limit'),
    sort: searchParams.get('sort'),
  });

  // params.page es number, con tipos
}

z.coerce.number() convierte string a número.

3. Reglas custom

const passwordSchema = z.string()
  .min(8)
  .refine((val) => /[A-Z]/.test(val), 'Must contain uppercase')
  .refine((val) => /[a-z]/.test(val), 'Must contain lowercase')
  .refine((val) => /[0-9]/.test(val), 'Must contain number');

Incluso async:

const emailSchema = z.string().email().refine(
  async (email) => {
    const exists = await checkEmailExists(email);
    return !exists;
  },
  'Email already taken'
);

4. Errores

if (!result.success) {
  const errors = result.error.errors.map(err => ({
    field: err.path.join('.'),
    message: err.message,
  }));

  return NextResponse.json({ errors }, { status: 400 });
}

Errores estructurados, fáciles de mostrar en el frontend.

Otras medidas de seguridad

Además de validar:

1. Protección CSRF

Server Actions de Next.js comparan Origin y Host. API Routes: tú mismo. NextAuth lo hace; manualmente, token CSRF en cookie y cabecera.

2. Content Security Policy (CSP)

En next.config.js:

{
  headers: [
    {
      key: 'Content-Security-Policy',
      value: "default-src 'self'; script-src 'self'; style-src 'self';",
    },
  ],
}

Con XSS, el script malicioso no carga.

3. Variables de entorno

En Next.js:

  • NEXT_PUBLIC_* va al cliente
  • Sin prefijo, solo servidor

Nunca claves en NEXT_PUBLIC_. Vi NEXT_PUBLIC_API_KEY filtrada.

4. SQL injection

Prisma, Drizzle: consultas parametrizadas. SQL crudo:

// ❌ Peligroso
db.query(`SELECT * FROM users WHERE id = ${userId}`);

// ✅ Seguro
db.query('SELECT * FROM users WHERE id = ?', [userId]);

5. Actualizar dependencias

npm audit
npm update

La vulnerabilidad de React de febrero se arregla actualizando. No lo pospongas.

Lista de verificación de seguridad completa

Resumen para revisar tu proyecto:

Autenticación

  • ✅ Token en cookie HttpOnly, no localStorage
  • ✅ Access token ≤ 30 minutos
  • ✅ Refresh token implementado
  • ✅ Clave JWT ≥ 32 caracteres en variable de entorno
  • ✅ Cookies con secure y sameSite
  • ✅ Middleware en APIs sensibles

CORS

  • ✅ Sin Access-Control-Allow-Origin: * en APIs sensibles
  • ✅ Lista explícita de dominios
  • ✅ OPTIONS Preflight correcto
  • Access-Control-Allow-Credentials: true cuando haga falta
  • ✅ Probar CORS en producción

Rate limiting

  • ✅ Límite estricto en login, registro, reset de contraseña
  • ✅ Políticas distintas autenticado vs anónimo
  • ✅ 429 y cabecera Retry-After
  • ✅ Redis o Upstash, no solo memoria en Serverless
  • ✅ Monitorizar disparos y ajustar umbrales

Validación de entrada

  • ✅ Toda entrada validada en servidor
  • ✅ Zod u herramienta similar
  • ✅ Límites de longitud en strings, arrays y objetos
  • ✅ Formatos: email, URL, fecha…
  • ✅ Errores de validación claros

Auditoría periódica

  • npm audit al menos mensual; arreglar críticos
  • ✅ Next.js y React en última estable
  • ✅ Seguir avisos de seguridad de Next.js
  • ✅ Revisar env: sin secretos en el cliente
  • ✅ En code review, auth y permisos primero

Imprime la lista y pásala antes de arrancar y antes de desplegar.

Conclusión

En una frase: la seguridad de la API es un sistema, no un checkbox único.

Auth, CORS, rate limiting, validación — parece mucho. Cuando hay filtración, caída del servidor o factura astronómica, remediar cuesta más.

Configurar todo desde cero me llevó medio día o un día. Después es copiar, pegar y ajustar; un proyecto nuevo en minutos. Y duermes tranquilo sin miedo a despertar hackeado.

Qué hacer ya:

  1. Revisa proyectos actuales con la lista
  2. Empieza por lo crítico: auth y rate limiting
  3. Suscríbete a avisos: blog de Next.js, GitHub Security Advisories
  4. Comparte con el equipo: seguridad es de todos

Último aviso: la vulnerabilidad CVSS 10.0 de React de diciembre de 2025 afectó a muchísimos proyectos. Si no actualizaste, hazlo ya. Parches de seguridad no esperan.

El camino es largo, pero cada paso vale. Ojalá este artículo te ahorre tropiezos y dejes la API bien protegida pronto.

FAQ

¿Debería la API de Next.js usar JWT o autenticación por sesión?
Depende del escenario. Despliegues Serverless y autenticación entre dominios encajan con JWT: sin estado y fácil de escalar. Escenarios que requieren control activo del servidor (expulsar usuarios) o seguridad extrema encajan con sesiones. Proyectos personales: JWT. Aplicaciones empresariales: sesiones.
¿Por qué no guardar el token JWT en localStorage?
Porque localStorage es accesible desde JavaScript: un ataque XSS exitoso puede robar el token. Usa cookies HttpOnly; JavaScript no puede leerlas, así que el token no se filtra aunque exista una vulnerabilidad XSS.
¿Por qué en desarrollo no hay error CORS y en producción sí?
En desarrollo, frontend y API están en localhost:3000 (mismo origen), sin cross-origin. En producción pueden estar en dominios distintos y el navegador aplica CORS. Solución: configura correctamente Access-Control-Allow-Origin en la API.
¿Cómo implementar rate limiting en entornos Serverless?
Recomendado: @upstash/ratelimit + Vercel KV. Cada petición Serverless puede ser una instancia nueva; la memoria no se comparte y el rate limiting en memoria falla. Upstash Redis persiste datos compartidos entre instancias, ideal para Serverless.
¿Cómo prevenir fuerza bruta de contraseñas en la API?
Protección en capas: 1) rate limiting estricto en login (p. ej. máx. 5 intentos por IP por minuto); 2) validar fortaleza de contraseña con Zod; 3) bloqueo de cuenta (5 fallos seguidos = 30 min bloqueado); 4) CAPTCHA para subir el coste del ataque.

16 min de lectura · Publicado el: 5 ene 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog