Saltar al contenido

Autenticación segura de APIs: JWT, OAuth y sesiones

Guía práctica para elegir e implementar autenticación segura en APIs modernas: tokens JWT, flujos OAuth 2.0 y los fallos típicos de sesión.

7 min de lectura
Icono de un escudo que protege los endpoints de la API con capas de autenticación

Por qué la autenticación de API sigue rota en 2024

Cada semana aparece un nuevo informe de brechas de seguridad. Tokens robados, secuestro de sesiones, flujos de OAuth mal implementados: no son ataques exóticos. Son el pan de cada día de la explotación moderna. La causa raíz es casi siempre la misma: los desarrolladores eligen una estrategia de autenticación sin entender sus contrapartidas.

La autenticación parece simple en la superficie. Verificas quién es alguien, le entregas una credencial y la compruebas en las solicitudes posteriores. Pero el diablo está en los detalles: el almacenamiento de tokens, las políticas de rotación, la gestión de scopes y una decena de aspectos más que los tutoriales suelen pasar por alto.

Esta guía repasa los tres enfoques dominantes de autenticación de API (JWT, OAuth 2.0 y sesiones del lado del servidor) y te muestra cómo implementar cada uno sin caer en los errores habituales que terminan en brechas de seguridad.

Autenticación JWT: poder y peligro

Los JSON Web Tokens se convirtieron en el estándar de facto para la autenticación sin estado. Un JWT contiene un encabezado, una carga útil (payload) y una firma codificados en base64. El servidor lo genera, el cliente lo almacena y cada solicitud lo devuelve.

El atractivo es evidente: no hace falta un almacén de sesiones en el servidor, permite escalado horizontal sin sticky sessions y facilita la verificación entre servicios. Pero los JWT conllevan riesgos reales cuando se implementan sin cuidado.

tstypescript
// ❌ Bad: Long-lived JWT with sensitive data in payload
import jwt from "jsonwebtoken";
 
function generateToken(user: User): string {
  return jwt.sign(
    {
      id: user.id,
      email: user.email,
      role: user.role,
      ssn: user.ssn, // Never put sensitive data in JWT
    },
    "my-secret-key", // Hardcoded secret
    { expiresIn: "30d" } // Way too long
  );
}
tstypescript
// ✅ Good: Short-lived JWT with minimal claims and proper key management
import jwt from "jsonwebtoken";
 
interface TokenPayload {
  sub: string;
  role: string;
  jti: string;
}
 
function generateAccessToken(user: User): string {
  const payload: TokenPayload = {
    sub: user.id,
    role: user.role,
    jti: crypto.randomUUID(),
  };
 
  return jwt.sign(payload, process.env.JWT_SECRET!, {
    algorithm: "HS256",
    expiresIn: "15m",
    issuer: "api.example.com",
    audience: "example.com",
  });
}
 
function generateRefreshToken(user: User): string {
  return jwt.sign(
    { sub: user.id, jti: crypto.randomUUID() },
    process.env.JWT_REFRESH_SECRET!,
    { algorithm: "HS256", expiresIn: "7d" }
  );
}

Los access tokens de corta duración (15 minutos o menos) combinados con refresh tokens limitan el radio de impacto de un token comprometido. El claim jti te da un identificador único para el seguimiento de revocaciones.

Almacenamiento de tokens: dónde la mayoría de los equipos se equivocan

Dónde almacenas los tokens en el cliente importa más que cómo los generas. LocalStorage es accesible para cualquier JavaScript que se ejecute en la página: basta una vulnerabilidad XSS para que tus tokens sean exfiltrados.

tstypescript
// ❌ Bad: Storing JWT in localStorage
function login(token: string): void {
  localStorage.setItem("access_token", token);
}
 
function getAuthHeader(): Record<string, string> {
  const token = localStorage.getItem("access_token");
  return { Authorization: `Bearer ${token}` };
}
tstypescript
// ✅ Good: HTTP-only cookies with proper flags
import { NextResponse } from "next/server";
 
function setAuthCookies(
  response: NextResponse,
  accessToken: string,
  refreshToken: string
): NextResponse {
  response.cookies.set("access_token", accessToken, {
    httpOnly: true,
    secure: true,
    sameSite: "strict",
    maxAge: 900, // 15 minutes
    path: "/",
  });
 
  response.cookies.set("refresh_token", refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: "strict",
    maxAge: 604800, // 7 days
    path: "/api/auth/refresh",
  });
 
  return response;
}

Las cookies HTTP-only no pueden ser leídas por JavaScript, lo que elimina el vector de robo de tokens vía XSS. El indicador sameSite: "strict" previene ataques CSRF al bloquear el envío de la cookie en solicitudes de origen cruzado. Limitar la ruta del refresh token únicamente al endpoint de refresco reduce su exposición.

Flujos de OAuth 2.0: cómo elegir el correcto

OAuth 2.0 no es autenticación, es autorización. Pero combinado con OpenID Connect se convierte en la base de los sistemas de identidad modernos. El problema es que OAuth define múltiples flujos, y elegir el incorrecto abre huecos de seguridad.

tstypescript
// Authorization Code Flow with PKCE (recommended for SPAs and mobile)
import crypto from "crypto";
 
function generatePKCE(): {
  codeVerifier: string;
  codeChallenge: string;
} {
  const codeVerifier = crypto.randomBytes(32).toString("base64url");
 
  const codeChallenge = crypto
    .createHash("sha256")
    .update(codeVerifier)
    .digest("base64url");
 
  return { codeVerifier, codeChallenge };
}
 
function buildAuthorizationUrl(
  clientId: string,
  redirectUri: string,
  codeChallenge: string
): string {
  const params = new URLSearchParams({
    response_type: "code",
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: "openid profile email",
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
    state: crypto.randomBytes(16).toString("hex"),
  });
 
  return `https://auth.example.com/authorize?${params.toString()}`;
}

El flujo Authorization Code con PKCE es hoy el enfoque recomendado para todo tipo de clientes. El flujo implícito está obsoleto: expone los tokens en el fragmento de la URL, que termina en el historial del navegador y en los logs del servidor.

tstypescript
// ❌ Bad: Implicit flow exposes tokens in URL
// redirect: https://app.com/callback#access_token=eyJ...&token_type=bearer
 
// ✅ Good: Authorization code exchange happens server-side
async function exchangeCodeForTokens(
  code: string,
  codeVerifier: string
): Promise<TokenResponse> {
  const response = await fetch("https://auth.example.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: process.env.OAUTH_REDIRECT_URI!,
      client_id: process.env.OAUTH_CLIENT_ID!,
      code_verifier: codeVerifier,
    }),
  });
 
  if (!response.ok) {
    throw new Error(`Token exchange failed: ${response.status}`);
  }
 
  return response.json();
}

Valida siempre el parámetro state en el callback para prevenir ataques CSRF contra el propio flujo de OAuth. Nunca te saltes este paso, ni siquiera en desarrollo.

Sesiones del lado del servidor: la opción infravalorada

Las sesiones pasaron de moda cuando los microservicios se impusieron, pero siguen siendo la opción más segura para aplicaciones monolíticas y backends-for-frontends. Un ID de sesión almacenado en una cookie HTTP-only, con el estado guardado en el servidor, te da revocación instantánea sin la complejidad de las listas negras de tokens.

tstypescript
import { Redis } from "ioredis";
import crypto from "crypto";
 
const redis = new Redis(process.env.REDIS_URL!);
const SESSION_TTL = 3600; // 1 hour
 
interface SessionData {
  userId: string;
  role: string;
  createdAt: number;
  lastActivity: number;
}
 
async function createSession(user: User): Promise<string> {
  const sessionId = crypto.randomBytes(32).toString("hex");
  const sessionData: SessionData = {
    userId: user.id,
    role: user.role,
    createdAt: Date.now(),
    lastActivity: Date.now(),
  };
 
  await redis.setex(
    `session:${sessionId}`,
    SESSION_TTL,
    JSON.stringify(sessionData)
  );
 
  return sessionId;
}
 
async function validateSession(
  sessionId: string
): Promise<SessionData | null> {
  const data = await redis.get(`session:${sessionId}`);
  if (!data) return null;
 
  const session: SessionData = JSON.parse(data);
  session.lastActivity = Date.now();
 
  await redis.setex(
    `session:${sessionId}`,
    SESSION_TTL,
    JSON.stringify(session)
  );
 
  return session;
}
 
async function revokeSession(sessionId: string): Promise<void> {
  await redis.del(`session:${sessionId}`);
}

La contrapartida es clara: las sesiones requieren un almacén compartido (Redis, una base de datos), lo que añade una dependencia y limita el escalado horizontal. A cambio, obtienes revocación inmediata, ningún sobrecoste por el tamaño del token y control total sobre el ciclo de vida de la sesión.

Patrones de middleware para la autenticación

La lógica de autenticación pertenece al middleware, no debe estar dispersa entre los manejadores de rutas. Una cadena de middleware bien diseñada valida las credenciales, extrae la identidad y la adjunta al contexto de la solicitud.

tstypescript
import { NextRequest, NextResponse } from "next/server";
import jwt from "jsonwebtoken";
 
interface AuthenticatedRequest extends NextRequest {
  user?: { sub: string; role: string };
}
 
function authMiddleware(
  handler: (req: AuthenticatedRequest) => Promise<NextResponse>
) {
  return async (req: AuthenticatedRequest): Promise<NextResponse> => {
    const token = req.cookies.get("access_token")?.value;
 
    if (!token) {
      return NextResponse.json(
        { error: "Authentication required" },
        { status: 401 }
      );
    }
 
    try {
      const payload = jwt.verify(token, process.env.JWT_SECRET!, {
        algorithms: ["HS256"],
        issuer: "api.example.com",
      }) as { sub: string; role: string };
 
      req.user = payload;
      return handler(req);
    } catch {
      return NextResponse.json(
        { error: "Invalid or expired token" },
        { status: 401 }
      );
    }
  };
}
 
function requireRole(...roles: string[]) {
  return (
    handler: (req: AuthenticatedRequest) => Promise<NextResponse>
  ) => {
    return authMiddleware(async (req: AuthenticatedRequest) => {
      if (!req.user || !roles.includes(req.user.role)) {
        return NextResponse.json(
          { error: "Insufficient permissions" },
          { status: 403 }
        );
      }
      return handler(req);
    });
  };
}

Fíjate en el campo algorithms dentro de jwt.verify. Sin él, un atacante podría enviar un token firmado con el algoritmo "none" y saltarse la verificación por completo. Especifica siempre el algoritmo esperado de forma explícita.

Rotación y revocación de refresh tokens

Los refresh tokens son de larga duración y muy poderosos. Si uno de ellos es robado, el atacante puede generar nuevos access tokens indefinidamente. La rotación de refresh tokens resuelve esto emitiendo un nuevo refresh token cada vez que se renueva el access token e invalidando el anterior.

tstypescript
async function rotateRefreshToken(
  currentRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
  let payload: { sub: string; jti: string };
 
  try {
    payload = jwt.verify(
      currentRefreshToken,
      process.env.JWT_REFRESH_SECRET!,
      { algorithms: ["HS256"] }
    ) as { sub: string; jti: string };
  } catch {
    throw new Error("Invalid refresh token");
  }
 
  const isRevoked = await redis.get(`revoked:${payload.jti}`);
  if (isRevoked) {
    await redis.del(`refresh_family:${payload.sub}`);
    throw new Error("Refresh token reuse detected — all sessions revoked");
  }
 
  await redis.setex(`revoked:${payload.jti}`, 604800, "true");
 
  const user = await getUserById(payload.sub);
  if (!user) throw new Error("User not found");
 
  return {
    accessToken: generateAccessToken(user),
    refreshToken: generateRefreshToken(user),
  };
}

El detalle crítico es detectar la reutilización de un refresh token. Si un token ya revocado se presenta de nuevo, significa que fue robado: tanto el usuario legítimo como el atacante tienen una copia. La respuesta correcta es revocar toda la familia de tokens y forzar una nueva autenticación.

Conclusiones clave

La autenticación de API es un espectro de contrapartidas. Los JWT te dan verificación sin estado a costa de la revocabilidad. Las sesiones te dan control a costa de infraestructura. OAuth te da delegación a costa de complejidad.

Los principios innegociables son los mismos sin importar el enfoque que elijas: almacena los tokens en cookies HTTP-only, mantén los access tokens de corta duración, valida todo en el servidor, especifica los algoritmos de forma explícita, implementa la rotación de refresh tokens y nunca pongas datos sensibles en el payload de un token.

La seguridad no es una función que se añade al final. Es una restricción alrededor de la cual se diseña desde el principio. La estrategia de autenticación que elijas el primer día condiciona cada endpoint de API que construyas después.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX