Seguridad APIs web
Una API web permite acceder a datos y funcionalidades de una aplicación. Pero, si no se protege, cualquiera podría: ver información privada, modificar datos que no debería, suplantar identidades, o incluso destruir información.
Por eso toda API que maneje datos sensibles necesita una forma de comprobar dos cosas:
- Quién eres (autenticación).
- Qué estás autorizado a hacer (autorización).
Y aquí entran en juego JWT y OAuth, dos piezas básicas hoy en día.
JWT: identificar a un usuario de forma segura
JWT (JSON Web Token) es una forma muy común de manejar la identidad en APIs. Imagina un sistema donde un usuario inicia sesión con su email y contraseña. El servidor comprueba que todo es correcto y le entrega un token JWT, que es como una tarjeta digital firmada.
Ese token: dice quién es el usuario, puede indicar qué permisos tiene, tiene una fecha de caducidad, no se puede falsificar porque va firmado.
Después, en cada petición, el cliente (web, móvil, etc.) envía ese token para demostrar quién es.
Es como entrar en un edificio con seguridad: enseñas tu DNI al guardia una vez, te dan un pase, con ese pase entras a las distintas salas sin volver a enseñar el DNI. Ese “pase” es el JWT.
Lo importante: JWT identifica a un usuario ante una API.
OAuth: dar permisos entre servicios sin compartir contraseñas
OAuth (específicamente OAuth 2.0) no sirve para que tú te identifiques directamente, sino para permitir que una aplicación X acceda a tus datos de la aplicación Y sin conocer tu contraseña.
Ejemplos reales muy conocidos:
- Entras en una web usando “Iniciar sesión con Google”.
- Permites que una app acceda a tus fotos de Google Drive.
- Una app de música accede a tu cuenta de Spotify.
- Una herramienta de terceros gestiona tus repositorios de GitHub.
Aquí el proceso es:
- Tú das permiso explícito.
- El proveedor (Google, GitHub, etc.) entrega a la aplicación un token de acceso para actuar en tu nombre.
- La aplicación usa ese token para llamar a la API del proveedor.
Lo importante: OAuth permite a una aplicación acceder a recursos de otra en tu nombre, sin revelar tu contraseña.
Diferencia básica entre ambos
- JWT identifica a un usuario frente a una API. Es para sesiones y autenticación en tu propia aplicación.
- OAuth permite que una aplicación externa obtenga permisos para usar tus datos. Es para delegar autorización entre servicios.
Por qué esto es importante en seguridad de APIs
Toda API seria debe controlar: quién está haciendo la petición, si esa persona o servicio tiene permiso para realizar esa operación, y durante cuánto tiempo.
- JWT resuelve la identidad del usuario.
- OAuth resuelve los permisos entre aplicaciones.
Esta es la base para cualquier API moderna: desde redes sociales hasta bancos, pasando por tiendas online, servicios en la nube o plataformas educativas.
¿Cómo funciona OAuth?
OAuth (Open Authorization) es un protocolo de autorización estándar que permite a las aplicaciones acceder a información de usuarios en otros servicios sin necesidad de compartir sus contraseñas.
El flujo básico de OAuth 2.0 (la versión más usada) sigue estos pasos:
1. Solicitud de Autorización
Tu aplicación redirige al usuario al proveedor de OAuth (como Google, GitHub, etc.) con:
- Tu
client_id(identificador de tu aplicación) - Los
scopes(permisos que necesitas) - Una
redirect_uri(donde volverá después)
2. Usuario Autoriza
El usuario ve una pantalla del proveedor preguntando: "¿La aplicación X quiere acceder a tu información Y?" Si acepta, el proveedor redirige de vuelta a tu aplicación con un código de autorización.
3. Intercambio por Token
Tu aplicación backend intercambia ese código por un token de acceso, haciendo una petición secreta (con tu client_secret) al proveedor.
4. Uso del Token
Con el token de acceso, tu aplicación puede hacer peticiones a la API del proveedor para obtener los datos autorizados.
Flujo del uso de Oauth en tu sistema
Entonces ¿qué parte tengo que programar yo? Tú, como desarrollador backend/frontend, tienes que escribir:
-
La URL de redirección (paso 1)
Eres tú quien construye la URL con: client_id, redirect_uri, scope, response_type
-
El endpoint /callback en tu servidor
Que reciba
code=XXXX. -
La petición POST al endpoint de tokens del proveedor
Porque tú debes intercambiar el
codepor elaccess_token. -
El uso del access_token para pedir datos protegidos
Haciendo peticiones con
Authorization: Bearer ....
Lo que NO programas tú:
- No creas tu propio servidor OAuth.
- No inventas tú los endpoints de Google.
- No firmas tokens.
- No gestionas contraseñas del usuario.
- No manejas tú la pantalla de login.
Qué sí “viene implementado” por Google (o el proveedor OAuth)
Google ofrece:
- La pantalla de login: tú no la diseñas ni la programas.
- La gestión de usuarios, contraseñas, seguridad, 2FA.
- El endpoint de autorización (
https://accounts.google.com/o/oauth2/auth). - El endpoint de intercambio de tokens (
https://oauth2.googleapis.com/token). - Los endpoints de APIs protegidas (
/userinfo, Drive API, Gmail API…).
¿Qué herramientas facilitan esto?
Aunque el flujo es estándar, existen librerías que te ayudan a no escribirlo todo a mano:
- Passport.js (Node)
- NextAuth (Next.js)
- Auth0 SDK
- Google API Client Libraries
Estas librerías implementan por ti parte del flujo, por ejemplo: construir la URL de login, validar el código code=xxx, intercambiarlo por tokens, efrescar tokens automáticamente.
Pero no son parte del estándar OAuth, son implementaciones externas que te ayudan.
Ejemplo practico simulado
Redirección al proveedor OAuth
// Esta línea redirige al usuario a la página oficial de Google para iniciar sesión.
// Aquí comienza el flujo de OAuth 2.0. Tú construyes la URL manualmente
// incluyendo tu client_id, redirect_uri y los permisos (scopes) que necesitas.
// El navegador abandona tu web y abre la pantalla de login de Google.
// Tú *no* controlas esta pantalla: Google la genera, maneja la autenticación, revisa contraseñas, 2FA, y todo lo relacionado con seguridad del usuario.
// IMPORTANTE: Esta parte la escribes tú porque necesitas personalizar: Qué permisos pides, a qué ruta debe volver Google, cuál es tu client_id registrado en Google Cloud.
window.location.href = `https://accounts.google.com/o/oauth2/auth?
client_id=TU_CLIENT_ID& // Identificador público de tu aplicación
redirect_uri=https://tuapp.com/callback& // URL a la que Google devolverá el 'code'
scope=email profile& // Permisos que solicitas al usuario
response_type=code`; // Indica que quieres un 'authorization code'
Intercambio de código temporal por un token de acceso (Backend)
// Este paso no puede hacerse en el frontend porque implica enviar el client_secret.
// Si expones el client_secret en el navegador, cualquiera podría robarlo y hacerse pasar por tu aplicación.
// En este paso, tu servidor (Node, Python, PHP, etc.) recibe el parámetro "code" desde la URL de callback. Ese code es temporal y de un solo uso.
// Ahora tu servidor debe enviarlo a Google para intercambiarlo por un access_token.
// Esta petición POST la escribes tú porque:
// - Debes enviar tu client_id y client_secret.
// - Debes usar tu redirect_uri exacta.
// - Debes manejar la respuesta para guardar el token o crear una sesión.
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST", // El estándar OAuth 2.0 exige que esta operación sea POST
headers: {
"Content-Type": "application/json" // Importante: enviamos JSON válido
},
body: JSON.stringify({
code: "AUTH_CODE", // Código temporal recibido en tu callback
client_id: "TU_CLIENT_ID", // El mismo client_id que usaste en la redirección inicial
client_secret: "TU_CLIENT_SECRET", // Secreto privado de tu app -> nunca en frontend
redirect_uri: "https://tuapp.com/callback", // Debe coincidir al 100% con la URL registrada
grant_type: "authorization_code" // Indica que estamos usando el flujo estándar OAuth
})
});
// Si todo va bien, Google responderá con un JSON que incluye:
// - access_token: permite pedir datos protegidos del usuario.
// - refresh_token (opcional)
// - expires_in
Solicitud de datos del usuario usando el access_token (Backend o frontend seguro)
// Una vez tienes un access_token válido, puedes hacer peticiones autenticadas a las APIs de Google. Aquí ya no necesitas contraseña, ni email, ni nada del usuario.
// Solo necesitas el token que Google te entregó.
// El encabezado Authorization usa el formato estándar:
// Authorization: Bearer TOKEN
// Con esto Google sabe que el usuario ya se autenticó y que tú tienes permiso para acceder a los datos que autorizó (email, perfil, fotos, etc.).
// Este código también lo implementas tú porque: decides qué endpoint usar, controlas qué datos quieres obtener, administras errores y flujos propios de tu app.
const userData = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
method: "GET",
headers: {
Authorization: `Bearer ${access_token}` // Token obtenido en el paso anterior
}
});
// La respuesta contendrá datos del usuario:
// {
// "id": "...",
// "email": "usuario@gmail.com",
// "verified_email": true,
// "name": "Nombre del Usuario",
// "picture": "https://..."
// }
// Desde aquí puedes crear su sesión interna, guardarlo en tu BD, etc.
¿Por qué es seguro?
- No compartes contraseñas: El usuario nunca da su contraseña a tu aplicación
- Tokens temporales: Los tokens expiran y se pueden revocar
- Permisos específicos: Solo accedes a lo que el usuario autorizó explícitamente
Ejemplo Completo de flujo OAuth
Desarrollo de una aplicación mínima que implemente el flujo OAuth 2.0 con Google utilizando Node.js nativo (sin frameworks) y ES Modules, junto con un frontend sencillo basado en Bootstrap.
La aplicación debe:
- Mostrar un botón “Iniciar sesión con Google”.
- Redirigir al usuario a Google para autenticarlo.
- Recibir el parámetro
codeenviado por Google al frontend. - Enviar ese
codea un servidor Node. - El servidor debe intercambiar el
codepor unaccess_token. - Obtener los datos básicos del usuario desde Google usando ese
access_token. - Devolver dichos datos al frontend para mostrarlos en pantalla.
El objetivo es comprender cómo se coordina el navegador, el backend y el proveedor OAuth en un flujo real de autenticación.
Estructura de archivos y directorios (árbol real)
oauth-google-demo/
├── package.json
├── public/
│ ├── index.html
│ └── app.js
└── src/
├── config/
│ └── oauthConfig.mjs
├── core/
│ ├── httpClient.mjs
│ └── server.mjs
├── oauth/
│ ├── oauthService.mjs
│ └── oauthRoutes.mjs
└── utils/
└── bodyReader.mjs
Contenidos del backend
src/config/oauthConfig.mjs
// src/config/oauthConfig.mjs
// Configuración centralizada para OAuth y el servidor.
export const OAUTH_CONFIG = {
PORT: process.env.PORT || 3000,
CLIENT_ID: process.env.CLIENT_ID || "TU_CLIENT_ID",
CLIENT_SECRET: process.env.CLIENT_SECRET || "TU_CLIENT_SECRET",
REDIRECT_URI:
process.env.REDIRECT_URI || "http://localhost:5500/public/index.html",
OAUTH_AUTHORIZE_URL: "https://accounts.google.com/o/oauth2/v2/auth",
OAUTH_TOKEN_URL: "https://oauth2.googleapis.com/token",
OAUTH_USER_API: "https://www.googleapis.com/oauth2/v2/userinfo"
};
src/core/httpClient.mjs
// src/core/httpClient.mjs
// Cliente HTTP genérico usando http/https nativo.
import http from "http";
import https from "https";
export function httpRequest(url, options = {}) {
return new Promise((resolve, reject) => {
const lib = url.startsWith("https://") ? https : http;
const req = lib.request(url, options, (res) => {
let body = "";
res.on("data", (chunk) => {
body += chunk;
});
res.on("end", () => {
try {
resolve({
statusCode: res.statusCode,
headers: res.headers,
data: JSON.parse(body)
});
} catch {
resolve({ statusCode: res.statusCode, headers: res.headers, data: body });
}
});
});
req.on("error", reject);
if (options.body) {
req.write(options.body);
}
req.end();
});
}
src/utils/bodyReader.mjs
// src/utils/bodyReader.mjs
// Utilidad para leer el body de una request.
export function readRequestBody(req) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => {
body += chunk.toString();
});
req.on("end", () => resolve(body));
req.on("error", reject);
});
}
src/oauth/oauthService.mjs
// src/oauth/oauthService.mjs
// Servicio encargado del flujo OAuth: intercambio de code por token
// y obtención de datos del usuario.
import { OAUTH_CONFIG } from "../config/oauthConfig.mjs";
import { httpRequest } from "../core/httpClient.mjs";
export async function exchangeCodeForToken(code) {
const payload = {
code,
client_id: OAUTH_CONFIG.CLIENT_ID,
client_secret: OAUTH_CONFIG.CLIENT_SECRET,
redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
grant_type: "authorization_code"
};
const body = JSON.stringify(payload);
const response = await httpRequest(OAUTH_CONFIG.OAUTH_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body)
},
body
});
if (response.statusCode !== 200) {
throw new Error(
`Error intercambiando code por token: ${response.statusCode} ${JSON.stringify(
response.data
)}`
);
}
return response.data;
}
export async function getUserInfo(accessToken) {
const response = await httpRequest(OAUTH_CONFIG.OAUTH_USER_API, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`
}
});
if (response.statusCode !== 200) {
throw new Error(`Error obteniendo perfil: ${response.statusCode}`);
}
return response.data;
}
src/oauth/oauthRoutes.mjs
// src/oauth/oauthRoutes.mjs
// Rutas relacionadas con OAuth.
import { readRequestBody } from "../utils/bodyReader.mjs";
import { exchangeCodeForToken, getUserInfo } from "./oauthService.mjs";
export async function handleOAuthCallback(req, res) {
const headers = {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
};
if (req.method !== "POST") {
res.writeHead(405, headers);
res.end(JSON.stringify({ error: "Método no permitido" }));
return;
}
try {
const raw = await readRequestBody(req);
const { code } = JSON.parse(raw);
if (!code) {
throw new Error("No se proporcionó el parámetro 'code'.");
}
const tokenData = await exchangeCodeForToken(code);
const user = await getUserInfo(tokenData.access_token);
res.writeHead(200, headers);
res.end(JSON.stringify({ success: true, user }));
} catch (error) {
res.writeHead(500, headers);
res.end(JSON.stringify({ success: false, error: error.message }));
}
}
src/core/server.mjs
// src/core/server.mjs
// Servidor HTTP nativo que enruta las peticiones.
import http from "http";
import { OAUTH_CONFIG } from "../config/oauthConfig.mjs";
import { handleOAuthCallback } from "../oauth/oauthRoutes.mjs";
export function startServer() {
const server = http.createServer((req, res) => {
const { url, method } = req;
if (url === "/oauth/callback" && method === "POST") {
handleOAuthCallback(req, res);
return;
}
if (url === "/health" && method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "OK" }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Ruta no encontrada" }));
});
server.listen(OAUTH_CONFIG.PORT, () => {
console.log(`Servidor en http://localhost:${OAUTH_CONFIG.PORT}`);
});
}
if (import.meta.url === `file://${process.argv[1]}`) {
startServer();
}
Contenidos del frontend
public/index.html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>OAuth con Google</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
</head>
<body class="p-5">
<div class="container">
<h1 class="mb-4">Demo OAuth con Google</h1>
<button id="btnLogin" class="btn btn-primary">
Iniciar sesión con Google
</button>
<div id="loading" class="mt-3 d-none">
<div class="spinner-border"></div>
<span class="ms-2">Cargando...</span>
</div>
<h3 class="mt-4">Datos del usuario</h3>
<pre id="userOutput" class="bg-light p-3 border rounded"></pre>
</div>
<script type="module" src="./app.js"></script>
</body>
</html>
public/app.js
// public/app.js
const CONFIG = {
CLIENT_ID: "TU_CLIENT_ID",
REDIRECT_URI: "http://localhost:5500/public/index.html",
OAUTH_AUTHORIZE_URL: "https://accounts.google.com/o/oauth2/v2/auth",
BACKEND_URL: "http://localhost:3000"
};
function buildAuthUrl() {
const params = new URLSearchParams({
client_id: CONFIG.CLIENT_ID,
redirect_uri: CONFIG.REDIRECT_URI,
response_type: "code",
scope: "email profile"
});
return `${CONFIG.OAUTH_AUTHORIZE_URL}?${params.toString()}`;
}
function getCodeFromUrl() {
return new URL(window.location.href).searchParams.get("code");
}
async function sendCodeToBackend(code) {
const res = await fetch(`${CONFIG.BACKEND_URL}/oauth/callback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code })
});
return res.json();
}
function setLoading(show) {
document.getElementById("loading").classList.toggle("d-none", !show);
}
document.getElementById("btnLogin").addEventListener("click", () => {
window.location.href = buildAuthUrl();
});
(async function main() {
const code = getCodeFromUrl();
if (!code) return;
setLoading(true);
const data = await sendCodeToBackend(code);
setLoading(false);
document.getElementById("userOutput").textContent = JSON.stringify(
data,
null,
2
);
})();
¿Qué es un JWT?
JWT (JSON Web Token) es un estándar abierto que define una forma compacta y autónoma de transmitir información de forma segura entre partes como un objeto JSON.
Un JWT es como un "pase de acceso" digital que contiene información sobre el usuario. Está formado por tres partes separadas por puntos:
header.payload.signature
Estructura de un JWT:
- Header: Especifica el tipo de token y el algoritmo de firma
- Payload: Contiene los "claims" o datos del usuario (id, nombre, permisos, expiración)
- Signature: Firma digital que verifica la autenticidad del token
¿Cómo funciona JWT?
Te lo muestro desarrollando una pequeña aplicación completa que muestre cómo usar JWT para proteger peticiones a una API web.
La aplicación incluye:
- Un servidor HTTP en Node.js sin frameworks (solo módulos nativos) que:
- Permita registrar usuarios (
POST /api/register). - Permita hacer login (
POST /api/login). - Devuelva un JWT cuando el registro o login sean correctos.
- Exponga una ruta protegida (
GET /api/profile) que solo responda si el cliente envía un JWT válido en la cabeceraAuthorization: Bearer <token>.
- Permita registrar usuarios (
- Un frontend sencillo con Bootstrap que:
- Permita registrar un usuario y hacer login con formularios.
- Guarde el JWT recibido.
- Use ese JWT para hacer una petición protegida a
/api/profile. - Muestre en pantalla los datos devueltos por la API.
El objetivo es entender, de forma práctica, cómo integrar JWT en consultas a APIs web: generación, envío en cabecera, verificación y acceso a rutas protegidas.
2. Estructura de archivos y directorios
jwt-api-demo/
├── package.json
├── users.json # Base de datos simple en JSON (se crea/actualiza desde el backend)
├── public/
│ ├── index.html # Interfaz con Bootstrap
│ └── app.js # Lógica de frontend: registro, login y llamada a /api/profile
└── src/
├── config/
│ └── appConfig.mjs # Configuración general (puerto, secreto JWT, rutas)
├── auth/
│ └── jwtManager.mjs # Generación y verificación de JWT (sin librerías externas)
├── db/
│ └── userDatabase.mjs # "Base de datos" de usuarios sobre users.json
└── http/
├── utils.mjs # Funciones: leer body, enviar JSON, manejar CORS
└── server.mjs # Serv HTTP, rutas /api/register, /api/login, /api/profile
Ejecuta npm init -y para generar el archivo package.json
{
"name": "jwt-api-demo",
"version": "1.0.0",
"type": "module",
"description": "Demo de API con JWT implementado manualmente",
"main": "src/http/server.mjs",
"scripts": {
"start": "node src/http/server.mjs",
"dev": "node --watch src/http/server.mjs"
},
"keywords": ["jwt", "api", "authentication", "nodejs"],
"author": "",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
}
Users.json
{
"users": [
{
"id": 1,
"email": "usuario@ejemplo.com",
"password": "ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f"
},
{
"id": 2,
"email": "ana@example.com",
"password": "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"
},
]
}
Contenido del backend
src/config/appConfig.mjs
Configuración centralizada de la app y de JWT.
// src/config/appConfig.mjs
// Configuración centralizada de la aplicación y del sistema JWT.
// Este archivo contiene todas las constantes de configuración en un solo lugar.
export const APP_CONFIG = {
// Puerto donde escuchará el servidor HTTP
// Usa la variable de entorno PORT si existe, sino el puerto 3000
PORT: process.env.PORT || 3000,
// Clave secreta para firmar y verificar los tokens JWT
// ⚠️ IMPORTANTE: En producción, NUNCA hardcodear esta clave
// Debe venir siempre de variables de entorno por seguridad
JWT_SECRET: process.env.JWT_SECRET || "cambia_esta_clave_secreta_en_produccion",
// Tiempo de vida del token en segundos
// En este caso: 60 segundos * 60 minutos = 1 hora
JWT_EXPIRATION_SECONDS: 60 * 60,
// Archivo JSON que funcionará como "base de datos" simple
// Aquí se guardarán los usuarios registrados
USERS_DB_FILE: "users.json"
};
src/auth/jwtManager.mjs
Implementación de JWT a mano (sin librerías). Muy parecido al código que traías, pero ordenado y sin cosas sobrantes.
// src/auth/jwtManager.mjs
// Módulo encargado de generar y verificar tokens JWT usando solo Node nativo.
// Implementación manual de JWT sin librerías externas.
import { createHmac } from "crypto"; // Módulo de crypto de Node.js para HMAC
import { APP_CONFIG } from "../config/appConfig.mjs";
/**
* Codifica una cadena a Base64 URL Safe
* JWT usa una variante de Base64 que es URL-safe:
* - Reemplaza '+' por '-'
* - Reemplaza '/' por '_'
* - Elimina los '=' de padding
*/
function base64UrlEncode(str) {
return Buffer.from(str)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
/**
* Decodifica una cadena Base64 URL Safe a texto normal
* Realiza la operación inversa a base64UrlEncode
*/
function base64UrlDecode(str) {
// Primero revertimos las sustituciones URL-safe
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
// Añadimos padding '=' si es necesario (Base64 requiere longitud múltiplo de 4)
while (base64.length % 4 !== 0) {
base64 += "=";
}
return Buffer.from(base64, "base64").toString();
}
/**
* Genera la firma HMAC SHA256 para un JWT
* La firma se crea aplicando HMAC-SHA256 al string "header.payload"
* usando la clave secreta. Esto garantiza la integridad del token.
*/
function generateSignature(data, secret) {
const hmac = createHmac("sha256", secret);
hmac.update(data);
return base64UrlEncode(hmac.digest());
}
/**
* Genera un token JWT para un usuario
* Un JWT tiene 3 partes: header.payload.signature
*/
export function generarToken(usuario) {
// 1. HEADER: Metadatos del token
const header = {
alg: "HS256", // Algoritmo: HMAC con SHA-256
typ: "JWT" // Tipo: JWT
};
// Timestamp actual en segundos (Unix timestamp)
const ahora = Math.floor(Date.now() / 1000);
// 2. PAYLOAD: Datos del usuario y claims del token
const payload = {
id: usuario.id, // ID del usuario
email: usuario.email, // Email del usuario
iat: ahora, // Issued At: cuándo fue emitido
exp: ahora + APP_CONFIG.JWT_EXPIRATION_SECONDS // Expiration: cuándo expira
};
// Codificar header y payload a Base64 URL Safe
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
// Crear el dato a firmar: "header.payload"
const data = `${encodedHeader}.${encodedPayload}`;
// Generar la firma HMAC
const signature = generateSignature(data, APP_CONFIG.JWT_SECRET);
// JWT completo: "header.payload.signature"
const token = `${data}.${signature}`;
// Logging para debugging
console.log("✅ Token generado para:", usuario.email);
console.log("📅 Expira en:", new Date(payload.exp * 1000).toLocaleString());
return token;
}
/**
* Verifica un token JWT:
* 1. Comprueba que tenga el formato correcto (3 partes)
* 2. Verifica la firma HMAC
* 3. Comprueba que no haya expirado
* Si todo está bien, devuelve el payload decodificado.
* Si algo falla, devuelve null.
*/
export function verificarToken(token) {
try {
console.log("🔍 Verificando token...");
// 1. Dividir el token en sus 3 partes
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("Formato de token inválido");
}
const [encodedHeader, encodedPayload, signature] = parts;
// 2. Recalcular la firma esperada
const data = `${encodedHeader}.${encodedPayload}`;
const expectedSignature = generateSignature(data, APP_CONFIG.JWT_SECRET);
// 3. Comparar la firma recibida con la esperada
if (signature !== expectedSignature) {
throw new Error("Firma inválida");
}
// 4. Decodificar el payload
const payload = JSON.parse(base64UrlDecode(encodedPayload));
// 5. Verificar la expiración
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
throw new Error("Token expirado");
}
console.log("✅ Token válido para:", payload.email);
return payload;
} catch (error) {
console.error("❌ Error verificando token:", error.message);
return null;
}
}
src/db/userDatabase.mjs
“Base de datos” sobre un archivo JSON. Muy simple y suficiente para el ejercicio.
// src/db/userDatabase.mjs
// Módulo que gestiona usuarios en un archivo JSON como "base de datos".
// Simula una base de datos real pero usando solo el sistema de archivos.
import { existsSync, readFileSync, writeFileSync, accessSync, constants } from "fs";
import { createHash } from "crypto";
import { APP_CONFIG } from "../config/appConfig.mjs";
/**
* Inicializa el archivo de base de datos si no existe.
* Crea el archivo users.json con un usuario por defecto si es la primera vez.
*/
export function initializeDatabase() {
console.log(`📂 Verificando base de datos: ${APP_CONFIG.USERS_DB_FILE}`);
console.log(`📂 Directorio actual: ${process.cwd()}`);
try {
// Verificar permisos de escritura en el directorio actual
accessSync('.', constants.W_OK);
console.log("✅ Permisos de escritura OK");
} catch (err) {
console.log("❌ Sin permisos de escritura en el directorio");
}
// Si el archivo no existe, crearlo con datos iniciales
if (!existsSync(APP_CONFIG.USERS_DB_FILE)) {
console.log("📝 Creando nuevo archivo de DB...");
const initialData = {
users: [
{
id: 1,
email: "usuario@ejemplo.com",
// Contraseña "password123" hasheada con SHA-256
// En producción usar bcrypt o argon2, esto es solo para demo
password: "ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f"
}
]
};
// Escribir el archivo JSON con formato bonito (2 espacios de indentación)
writeFileSync(
APP_CONFIG.USERS_DB_FILE,
JSON.stringify(initialData, null, 2),
"utf8"
);
console.log("✅ Base de datos inicializada en", APP_CONFIG.USERS_DB_FILE);
console.log("👤 Usuario por defecto: usuario@ejemplo.com / password123");
} else {
console.log("📁 Base de datos ya existe");
// Mostrar información de los usuarios existentes
try {
const currentData = readFileSync(APP_CONFIG.USERS_DB_FILE, 'utf8');
const users = JSON.parse(currentData).users || [];
console.log(`📊 Usuarios en DB: ${users.length}`);
users.forEach(user => {
console.log(` - ${user.email} (ID: ${user.id})`);
});
} catch (error) {
console.log("❌ Error leyendo DB existente:", error.message);
}
}
}
/**
* Lee y devuelve todos los usuarios desde el archivo JSON.
* Esta función se llama cada vez que necesitamos acceder a los usuarios.
*/
function readUsers() {
try {
// Asegurarse de que la DB esté inicializada
initializeDatabase();
// Leer y parsear el archivo JSON
const raw = readFileSync(APP_CONFIG.USERS_DB_FILE, "utf8");
const data = JSON.parse(raw);
return data.users || []; // Devolver array vacío si no hay usuarios
} catch (error) {
console.error("❌ Error leyendo usuarios:", error);
return []; // En caso de error, devolver array vacío
}
}
/**
* Guarda el array de usuarios en el archivo JSON.
* Sobrescribe todo el archivo con los nuevos datos.
*/
function writeUsers(users) {
try {
const data = { users };
writeFileSync(APP_CONFIG.USERS_DB_FILE, JSON.stringify(data, null, 2), "utf8");
console.log("💾 Usuarios guardados correctamente. Total:", users.length);
return true;
} catch (error) {
console.error("❌ Error guardando usuarios:", error);
throw error; // Relanzar el error para que lo maneje el caller
}
}
/**
* Hashea una contraseña con SHA-256.
* ⚠️ EN PRODUCCIÓN: Usar bcrypt, argon2 o scrypt en lugar de SHA-256
* SHA-256 es rápido y vulnerable a ataques de fuerza bruta.
* Se usa aquí solo por simplicidad del ejemplo.
*/
function hashPassword(password) {
return createHash("sha256").update(password).digest("hex");
}
/**
* Verifica las credenciales de un usuario.
* Compara el email y contraseña (hasheada) con los almacenados.
* Devuelve { id, email } si son correctas o null si no.
*/
export function verificarCredenciales(email, password) {
try {
console.log(`🔐 Verificando credenciales para: ${email}`);
const users = readUsers();
// Buscar usuario por email (case-sensitive)
const user = users.find((u) => u.email === email);
if (!user) {
console.log("❌ Usuario no encontrado:", email);
return null;
}
// Hashear la contraseña proporcionada para comparar
const hashed = hashPassword(password);
// Debug: mostrar primeros caracteres del hash para verificar
console.log(`🔑 Comparando contraseñas:
- Input: ${password} -> ${hashed.substring(0, 10)}...
- DB: ${user.password.substring(0, 10)}...`);
// Comparar los hashes (tiempo constante para evitar timing attacks)
if (user.password !== hashed) {
console.log("❌ Contraseña incorrecta para:", email);
return null;
}
console.log("✅ Credenciales válidas para:", email);
// Devolver solo datos públicos, no la contraseña
return { id: user.id, email: user.email };
} catch (error) {
console.error("❌ Error verificando credenciales:", error);
return null;
}
}
/**
* Crea un nuevo usuario en la base de datos.
* Verifica que el email no exista antes de crear.
* Devuelve { id, email } del usuario creado.
*/
export function crearUsuario(email, password) {
try {
console.log(`📝 Creando usuario: ${email}`);
const users = readUsers();
// Verificar si el usuario ya existe
const usuarioExistente = users.find((u) => u.email === email);
if (usuarioExistente) {
console.log("❌ Usuario ya existe:", email);
throw new Error("El usuario ya existe");
}
// Generar nuevo ID (máximo ID existente + 1)
const newId = users.length > 0 ? Math.max(...users.map((u) => u.id)) + 1 : 1;
// Crear objeto de usuario
const newUser = {
id: newId,
email,
password: hashPassword(password) // Guardar solo el hash, nunca la contraseña en texto plano
};
// Añadir a la lista y guardar
users.push(newUser);
const success = writeUsers(users);
if (!success) {
throw new Error("Error guardando usuario");
}
console.log("✅ Usuario creado exitosamente:", email, "ID:", newId);
return { id: newUser.id, email: newUser.email };
} catch (error) {
console.error("❌ Error creando usuario:", error);
throw error;
}
}
/**
* Obtiene todos los usuarios (solo para debugging)
* No usar en producción porque expone información sensible
*/
export function obtenerTodosLosUsuarios() {
const users = readUsers();
console.log(`👥 Total de usuarios en DB: ${users.length}`);
users.forEach(user => {
console.log(` - ${user.email} (ID: ${user.id})`);
});
return users;
}
src/http/utils.mjs
Funciones auxiliares para leer el body y enviar respuestas JSON con CORS.
// src/http/utils.mjs
// Funciones auxiliares comunes para el servidor HTTP.
// Contiene utilidades para manejar requests, responses, CORS, etc.
import { APP_CONFIG } from "../config/appConfig.mjs";
/**
* Lee el cuerpo (body) de una petición HTTP y lo devuelve como string.
* Los bodies de HTTP llegan en chunks, así que hay que acumularlos.
*/
export function readRequestBody(req) {
return new Promise((resolve, reject) => {
let body = "";
// Acumular chunks de datos
req.on("data", (chunk) => {
body += chunk.toString();
});
// Cuando termina de llegar todo el body
req.on("end", () => {
console.log(`📨 Body recibido (${body.length} chars):`, body);
resolve(body);
});
// Manejar errores de lectura
req.on("error", (err) => {
console.error("❌ Error leyendo body:", err);
reject(err);
});
// Timeout de seguridad para evitar que se quede colgado
req.setTimeout(10000, () => {
console.error("⏰ Timeout leyendo body");
reject(new Error("Timeout reading request body"));
});
});
}
/**
* Envía una respuesta JSON con cabeceras apropiadas (incluyendo CORS).
* Esta función estandariza todas las respuestas JSON de la API.
*/
export function sendJson(res, statusCode, data) {
// Cabeceras para respuestas JSON con CORS habilitado
const headers = {
"Content-Type": "application/json; charset=utf-8",
"Access-Control-Allow-Origin": "*", // Permitir cualquier origen (en desarrollo)
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Requested-With"
};
// Logging diferenciado por estado (éxito/error)
const statusMessage = statusCode >= 400 ? "❌" : "✅";
console.log(`${statusMessage} Respuesta [${statusCode}]:`, JSON.stringify(data).substring(0, 200) + (JSON.stringify(data).length > 200 ? "..." : ""));
// Enviar respuesta
res.writeHead(statusCode, headers);
res.end(JSON.stringify(data, null, 2)); // Pretty print para mejor legibilidad
}
/**
* Manejo genérico de preflight CORS (método OPTIONS).
* Los navegadores envían requests OPTIONS antes de algunos requests para verificar CORS.
*/
export function handleCorsPreflight(req, res) {
if (req.method === "OPTIONS") {
console.log("🔄 Manejo de preflight CORS para:", req.url);
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Requested-With",
"Access-Control-Max-Age": "86400" // Cachear preflight por 24 horas
};
res.writeHead(200, headers);
res.end();
return true; // Indicar que ya se manejó la request
}
return false; // No era una preflight request
}
/**
* Función auxiliar para logging de requests
* Registra información básica de cada request que llega al servidor
*/
export function logRequest(req) {
const timestamp = new Date().toISOString();
const authHeader = req.headers.authorization ?
`[Auth: ${req.headers.authorization.substring(0, 20)}...]` :
'[Sin auth]';
console.log(`📍 ${timestamp} - ${req.method} ${req.url} ${authHeader}`);
}
/**
* Maneja errores de parseo JSON de forma segura
* En lugar de crashear, devuelve un valor por defecto
*/
export function parseJsonSafely(str, defaultValue = {}) {
try {
return JSON.parse(str || "{}");
} catch (error) {
console.error("❌ Error parseando JSON:", error.message, "Input:", str);
return defaultValue;
}
}
/**
* Valida que un objeto tenga las propiedades requeridas
* Útil para verificar que los requests tengan todos los campos necesarios
*/
export function validateRequiredFields(obj, requiredFields) {
const missing = requiredFields.filter(field => !obj[field]);
if (missing.length > 0) {
throw new Error(`Campos requeridos faltantes: ${missing.join(', ')}`);
}
return true;
}
src/http/server.mjs
Servidor HTTP con las rutas /api/register, /api/login, /api/profile y /health.
// src/http/server.mjs
// Servidor HTTP nativo que expone la API de autenticación y la ruta protegida.
// Este es el corazón de la aplicación - maneja todas las rutas y requests.
import http from "http";
import { URL } from "url";
import { readFileSync, existsSync } from "fs";
import { join } from "path";
import { fileURLToPath } from "url";
// Configuración de paths para ES modules (alternativa a __dirname)
const __filename = fileURLToPath(import.meta.url);
const __dirname = join(fileURLToPath(new URL('.', import.meta.url)));
// Importar módulos de la aplicación
import { APP_CONFIG } from "../config/appConfig.mjs";
import { readRequestBody, sendJson, handleCorsPreflight, logRequest, parseJsonSafely, validateRequiredFields } from "./utils.mjs";
import { generarToken, verificarToken } from "../auth/jwtManager.mjs";
import { verificarCredenciales, crearUsuario, initializeDatabase, obtenerTodosLosUsuarios } from "../db/userDatabase.mjs";
/**
* Sirve archivos estáticos desde la carpeta public
* Esto permite que el frontend (HTML, JS, CSS) sea servido por el mismo servidor
*/
function serveStaticFile(req, res, filePath) {
try {
// Construir ruta absoluta al archivo
const fullPath = join(process.cwd(), 'public', filePath);
console.log(`📁 Intentando servir archivo: ${fullPath}`);
// Verificar que el archivo existe
if (!existsSync(fullPath)) {
console.log(`❌ Archivo no encontrado: ${fullPath}`);
return sendJson(res, 404, { error: "Archivo no encontrado" });
}
// Leer contenido del archivo
const content = readFileSync(fullPath, 'utf8');
// Determinar Content-Type basado en la extensión
let contentType = 'text/html';
if (filePath.endsWith('.js')) contentType = 'application/javascript';
if (filePath.endsWith('.css')) contentType = 'text/css';
if (filePath.endsWith('.json')) contentType = 'application/json';
if (filePath.endsWith('.ico')) contentType = 'image/x-icon';
console.log(`✅ Sirviendo: ${filePath} (${contentType})`);
// Enviar archivo con cabeceras apropiadas
res.writeHead(200, {
'Content-Type': contentType,
'Access-Control-Allow-Origin': '*'
});
res.end(content);
} catch (error) {
console.error("❌ Error sirviendo archivo estático:", error);
sendJson(res, 500, { error: "Error interno del servidor" });
}
}
/**
* Maneja la ruta POST /api/register
* Crea un nuevo usuario en la base de datos y devuelve un JWT
*/
async function handleRegister(req, res) {
try {
// Leer y parsear el body de la request
const raw = await readRequestBody(req);
console.log("📝 Raw register data:", raw);
const data = parseJsonSafely(raw);
const { email, password } = data;
// Validar campos requeridos
if (!email || !password) {
return sendJson(res, 400, {
error: "Email y contraseña son requeridos",
received: data
});
}
// Validar longitud mínima de contraseña
if (password.length < 6) {
return sendJson(res, 400, {
error: "La contraseña debe tener al menos 6 caracteres"
});
}
// Crear usuario en la base de datos
const usuario = crearUsuario(email, password);
// Generar token JWT para el nuevo usuario
const token = generarToken(usuario);
console.log("✅ Usuario registrado:", usuario.email);
// Enviar respuesta exitosa
sendJson(res, 201, {
token,
usuario,
message: "Usuario registrado correctamente"
});
} catch (error) {
console.error("❌ Error en registro:", error);
// Manejar error específico de usuario ya existente
if (error.message === "El usuario ya existe") {
return sendJson(res, 409, { error: "El usuario ya existe" });
}
// Error genérico del servidor
sendJson(res, 500, {
error: "Error interno del servidor",
details: error.message
});
}
}
/**
* Maneja la ruta POST /api/login
* Verifica credenciales y devuelve un JWT si son válidas
*/
async function handleLogin(req, res) {
try {
const raw = await readRequestBody(req);
console.log("🔐 Raw login data:", raw);
const data = parseJsonSafely(raw);
const { email, password } = data;
// Validar campos requeridos
if (!email || !password) {
return sendJson(res, 400, {
error: "Email y contraseña son requeridos",
received: data
});
}
// Verificar credenciales contra la base de datos
const usuario = verificarCredenciales(email, password);
if (!usuario) {
console.log("❌ Credenciales inválidas para:", email);
return sendJson(res, 401, { error: "Credenciales inválidas" });
}
// Generar token JWT para el usuario autenticado
const token = generarToken(usuario);
console.log("✅ Login exitoso:", usuario.email);
sendJson(res, 200, {
token,
usuario,
message: "Login exitoso"
});
} catch (error) {
console.error("❌ Error en login:", error);
sendJson(res, 500, {
error: "Error interno del servidor",
details: error.message
});
}
}
/**
* Extrae y verifica el token JWT del header Authorization.
* Esta función se usa para proteger rutas que requieren autenticación.
*/
function getUserFromAuthHeader(req) {
const authHeader = req.headers.authorization;
// Verificar que el header Authorization existe y tiene el formato correcto
if (!authHeader || !authHeader.startsWith("Bearer ")) {
console.log("❌ No hay token o formato incorrecto");
return null;
}
// Extraer el token (remover "Bearer ")
const token = authHeader.slice("Bearer ".length).trim();
console.log("🔍 Token recibido:", token.substring(0, 20) + "...");
// Verificar validez del token
const payload = verificarToken(token);
if (!payload) {
console.log("❌ Token inválido o expirado");
} else {
console.log("✅ Token válido para usuario:", payload.email);
}
return payload;
}
/**
* Maneja la ruta GET /api/profile
* Ruta protegida: requiere un JWT válido en el header Authorization
*/
async function handleProfile(req, res) {
// Extraer y verificar el token del header
const userPayload = getUserFromAuthHeader(req);
if (!userPayload) {
return sendJson(res, 401, {
error: "Token de autenticación inválido o faltante"
});
}
// Si el token es válido, devolver datos del perfil
sendJson(res, 200, {
usuario: {
id: userPayload.id,
email: userPayload.email
},
mensaje: "Acceso autorizado a perfil protegido",
timestamp: new Date().toISOString()
});
}
/**
* Manejador principal de rutas.
* Todas las requests HTTP pasan por aquí y son enrutadas a la función apropiada.
*/
async function requestHandler(req, res) {
// Log de cada request que llega
logRequest(req);
// Manejo de preflight CORS (requests OPTIONS)
if (handleCorsPreflight(req, res)) return;
// Parsear URL para obtener pathname y query parameters
const url = new URL(req.url, `http://${req.headers.host}`);
console.log("🛣️ Ruta solicitada:", url.pathname);
// --- SERVIR ARCHIVOS ESTÁTICOS ---
// Estas rutas sirven el frontend (HTML, JS, CSS)
if (url.pathname === "/" || url.pathname === "/index.html") {
return serveStaticFile(req, res, "index.html");
}
if (url.pathname === "/app.js") {
return serveStaticFile(req, res, "app.js");
}
if (url.pathname === "/users.json") {
return serveStaticFile(req, res, "users.json");
}
// --- API ROUTES ---
// Endpoints de la API REST
if (url.pathname === "/api/register" && req.method === "POST") {
return handleRegister(req, res);
}
if (url.pathname === "/api/login" && req.method === "POST") {
return handleLogin(req, res);
}
if (url.pathname === "/api/profile" && req.method === "GET") {
return handleProfile(req, res);
}
// --- UTILITY ROUTES ---
// Health check para verificar que el servidor está funcionando
if (url.pathname === "/health" && req.method === "GET") {
return sendJson(res, 200, {
status: "OK",
timestamp: new Date().toISOString(),
database: "Initialized",
endpoints: [
"POST /api/register",
"POST /api/login",
"GET /api/profile",
"GET /health"
]
});
}
// Ruta de debug para obtener información del sistema
if (url.pathname === "/debug" && req.method === "GET") {
const users = obtenerTodosLosUsuarios();
return sendJson(res, 200, {
server: "Running",
port: APP_CONFIG.PORT,
users_count: users.length,
users: users.map(u => ({ id: u.id, email: u.email })) // No exponer passwords
});
}
// Si no coincide con ninguna ruta conocida, devolver 404
console.log("❌ Ruta no encontrada:", url.pathname);
sendJson(res, 404, { error: "Ruta no encontrada" });
}
/**
* Arranca el servidor HTTP.
* Esta función inicializa la base de datos y pone el servidor a escuchar.
*/
export function startServer() {
console.log("🚀 INICIANDO SERVIDOR JWT DEMO...");
// Inicializar base de datos al arrancar
initializeDatabase();
// Crear servidor HTTP
const server = http.createServer((req, res) => {
// Manejar errors no controlados en las promises
requestHandler(req, res).catch((err) => {
console.error("💥 Error no controlado:", err);
sendJson(res, 500, { error: "Error interno del servidor" });
});
});
// Poner el servidor a escuchar en el puerto configurado
server.listen(APP_CONFIG.PORT, () => {
console.log("\n🎉 Servidor escuchando en http://localhost:" + APP_CONFIG.PORT);
console.log("📊 Endpoints disponibles:");
console.log(" POST /api/register -> Registro, devuelve JWT");
console.log(" POST /api/login -> Login, devuelve JWT");
console.log(" GET /api/profile -> Ruta protegida (requiere JWT)");
console.log(" GET /health -> Estado del servidor");
console.log(" GET /debug -> Información de debug");
console.log(" GET / -> Interfaz web\n");
console.log("👤 Usuario de prueba:");
console.log(" Email: usuario@ejemplo.com");
console.log(" Password: password123\n");
});
return server;
}
startServer();
Contenido del frontend
public/index.html
Interfaz con Bootstrap 5 (CDN) y un contenedor simple para registro, login y consulta de perfil.
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Demo JWT API - Sistema Completo</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Bootstrap 5 por CDN - Framework CSS para estilos responsive -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<style>
.card {
margin-bottom: 1rem;
}
pre {
font-size: 0.8rem;
max-height: 300px;
overflow-y: auto;
}
.spinner-border-sm {
width: 1rem;
height: 1rem;
}
</style>
</head>
<body class="bg-light">
<div class="container py-5">
<div class="row">
<div class="col-12">
<h1 class="mb-4">🚀 Demo JWT - Sistema Completo</h1>
<p class="lead">Sistema de autenticación con JWT implementado manualmente</p>
</div>
</div>
<!-- Placeholder para mensajes de alerta (éxito, error, info) -->
<div id="alertPlaceholder"></div>
<div class="row">
<!-- Tarjeta de Registro -->
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">📝 Registro</h5>
<form id="registerForm">
<div class="mb-3">
<label for="registerEmail" class="form-label">Email</label>
<input type="email" class="form-control" id="registerEmail"
placeholder="tu@email.com" required />
</div>
<div class="mb-3">
<label for="registerPassword" class="form-label">Contraseña</label>
<input type="password" class="form-control" id="registerPassword"
placeholder="Mínimo 6 caracteres" required />
<div class="form-text">
Mínimo 6 caracteres (solo para el ejemplo).
</div>
</div>
<button type="submit" class="btn btn-primary">
Registrarse
</button>
</form>
</div>
</div>
</div>
<!-- Tarjeta de Login -->
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">🔐 Login</h5>
<form id="loginForm">
<div class="mb-3">
<label for="loginEmail" class="form-label">Email</label>
<input type="email" class="form-control" id="loginEmail"
placeholder="usuario@ejemplo.com" required />
</div>
<div class="mb-3">
<label for="loginPassword" class="form-label">Contraseña</label>
<input type="password" class="form-control" id="loginPassword"
placeholder="password123" required />
</div>
<button type="submit" class="btn btn-success">
Iniciar sesión
</button>
</form>
</div>
</div>
</div>
</div>
<!-- Sección para mostrar el Token JWT -->
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title">🔑 Token JWT Actual</h5>
<pre id="tokenOutput" class="bg-light p-3 border rounded">(sin token almacenado)</pre>
<button id="btnClearToken" class="btn btn-outline-danger btn-sm mt-2">
Limpiar Token
</button>
</div>
</div>
<!-- Sección para probar la ruta protegida -->
<div class="card">
<div class="card-body">
<h5 class="card-title">
🛡️ Llamada a ruta protegida <code>/api/profile</code>
</h5>
<p class="text-muted">Requiere un token JWT válido en el header Authorization</p>
<button id="btnGetProfile" class="btn btn-outline-primary mb-3">
Obtener perfil protegido
</button>
<pre id="profileOutput" class="bg-light p-3 border rounded"></pre>
</div>
</div>
<!-- Información de ayuda -->
<div class="card mt-4">
<div class="card-body">
<h5 class="card-title">ℹ️ Información</h5>
<p><strong>Usuario de prueba:</strong> usuario@ejemplo.com / password123</p>
<p><strong>Endpoints:</strong></p>
<ul>
<li><code>POST /api/register</code> - Registrar nuevo usuario</li>
<li><code>POST /api/login</code> - Iniciar sesión</li>
<li><code>GET /api/profile</code> - Ruta protegida</li>
<li><code>GET /health</code> - Estado del servidor</li>
<li><code>GET /debug</code> - Información de debug</li>
</ul>
</div>
</div>
</div>
<!-- Bootstrap JavaScript (para componentes interactivos) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Nuestra aplicación JavaScript del frontend -->
<script type="module" src="./app.js"></script>
</body>
</html>
public/app.js
Lógica del frontend: envía peticiones a la API, guarda el token en localStorage y lo usa en la cabecera Authorization.
// public/app.js
// Frontend de ejemplo para consumir la API protegida con JWT.
// Esta es la lógica del cliente que se ejecuta en el navegador.
// URL base del backend - apunta a nuestro servidor Node.js
const API_BASE_URL = "http://localhost:3000";
// Clave usada para guardar el token en localStorage del navegador
const TOKEN_STORAGE_KEY = "jwt_demo_token";
console.log("🔄 Frontend inicializado - Demo JWT API");
/**
* Muestra una alerta con Bootstrap en la parte superior.
* Las alertas son temporales y se auto-cierran después de 5 segundos.
*/
function showAlert(message, type = "info") {
const placeholder = document.getElementById("alertPlaceholder");
// Crear HTML de la alerta con Bootstrap
placeholder.innerHTML = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
<strong>${type === 'success' ? '✅' : type === 'danger' ? '❌' : 'ℹ️'}</strong>
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Cerrar"></button>
</div>
`;
// Auto-dismiss después de 5 segundos para success/info
if (type === 'success' || type === 'info') {
setTimeout(() => {
const alert = placeholder.querySelector('.alert');
if (alert) {
// Usar Bootstrap para cerrar la alerta suavemente
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}
}, 5000);
}
}
/**
* Guarda el token JWT en localStorage y lo muestra en la interfaz.
* localStorage persiste entre sesiones del navegador.
*/
function setToken(token) {
if (token) {
localStorage.setItem(TOKEN_STORAGE_KEY, token);
console.log("💾 Token guardado en localStorage");
} else {
localStorage.removeItem(TOKEN_STORAGE_KEY);
console.log("🗑️ Token removido de localStorage");
}
renderToken();
}
/**
* Lee el token actual desde localStorage.
* Si no hay token, devuelve string vacío.
*/
function getToken() {
return localStorage.getItem(TOKEN_STORAGE_KEY) || "";
}
/**
* Muestra el token actual en el <pre> correspondiente.
* Si hay token, lo decodifica para mostrar información útil.
*/
function renderToken() {
const tokenOutput = document.getElementById("tokenOutput");
const token = getToken();
if (token) {
// Decodificar el token para mostrar información (solo para demo)
try {
// Los JWT tienen 3 partes separadas por puntos: header.payload.signature
// La parte del payload es la segunda y está en Base64
const payload = JSON.parse(atob(token.split('.')[1]));
const expDate = new Date(payload.exp * 1000).toLocaleString();
// Mostrar token + información decodificada
tokenOutput.textContent = `Token: ${token}\n\n📋 Información del token:\n- Usuario: ${payload.email}\n- Expira: ${expDate}\n- ID: ${payload.id}`;
} catch (e) {
// Si hay error al decodificar, mostrar solo el token
tokenOutput.textContent = token;
}
} else {
tokenOutput.textContent = "(sin token almacenado)";
}
}
/**
* Muestra u oculta el loading en un botón
* Proporciona feedback visual durante las operaciones asíncronas
*/
function setButtonLoading(button, isLoading) {
if (isLoading) {
button.disabled = true;
const originalText = button.innerHTML;
button.setAttribute('data-original-text', originalText);
button.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Procesando...';
} else {
button.disabled = false;
const originalText = button.getAttribute('data-original-text');
if (originalText) {
button.innerHTML = originalText;
}
}
}
/**
* Envía una petición de registro a la API.
* Crea un nuevo usuario y recibe un JWT.
*/
async function register(email, password) {
console.log("📝 Intentando registro para:", email);
const button = document.querySelector('#registerForm button[type="submit"]');
setButtonLoading(button, true);
try {
// Enviar request POST a /api/register
const res = await fetch(`${API_BASE_URL}/api/register`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ email, password })
});
const data = await res.json();
console.log("📨 Respuesta registro:", data);
// Si la respuesta no es OK, lanzar error
if (!res.ok) {
throw new Error(data.error || `Error en registro (${res.status})`);
}
return data;
} finally {
// Siempre quitar el loading, haya éxito o error
setButtonLoading(button, false);
}
}
/**
* Envía una petición de login a la API.
* Verifica credenciales y recibe un JWT.
*/
async function login(email, password) {
console.log("🔐 Intentando login para:", email);
const button = document.querySelector('#loginForm button[type="submit"]');
setButtonLoading(button, true);
try {
const res = await fetch(`${API_BASE_URL}/api/login`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ email, password })
});
const data = await res.json();
console.log("📨 Respuesta login:", data);
if (!res.ok) {
throw new Error(data.error || `Error en login (${res.status})`);
}
return data;
} finally {
setButtonLoading(button, false);
}
}
/**
* Llama a la ruta protegida /api/profile usando el token en Authorization.
* Esta ruta requiere autenticación JWT.
*/
async function getProtectedProfile() {
const token = getToken();
console.log("🔍 Verificando token para perfil protegido...");
if (!token) {
throw new Error("No hay token almacenado. Haz login o registro primero.");
}
const button = document.getElementById('btnGetProfile');
const originalText = button.textContent;
button.textContent = "Cargando...";
button.disabled = true;
try {
// Enviar request con token en header Authorization
const res = await fetch(`${API_BASE_URL}/api/profile`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`, // JWT en formato Bearer
"Content-Type": "application/json"
}
});
const data = await res.json();
console.log("📨 Respuesta perfil:", data);
if (!res.ok) {
throw new Error(data.error || `Error al obtener perfil (${res.status})`);
}
return data;
} finally {
// Restaurar estado del botón
button.textContent = originalText;
button.disabled = false;
}
}
/**
* Limpia los formularios (restablece los campos)
*/
function clearForms() {
document.getElementById('registerForm').reset();
document.getElementById('loginForm').reset();
}
/**
* Configura los manejadores de los formularios y botones.
* Esta función se ejecuta cuando el DOM está listo.
*/
function setupEventHandlers() {
const registerForm = document.getElementById("registerForm");
const loginForm = document.getElementById("loginForm");
const btnGetProfile = document.getElementById("btnGetProfile");
const btnClearToken = document.getElementById("btnClearToken");
const profileOutput = document.getElementById("profileOutput");
// Botón para limpiar token
btnClearToken.addEventListener("click", () => {
setToken(null);
showAlert("Token limpiado correctamente", "info");
profileOutput.textContent = "";
});
// Submit del formulario de registro
registerForm.addEventListener("submit", async (e) => {
e.preventDefault(); // Prevenir envío tradicional del formulario
// Obtener valores de los campos
const email = document.getElementById("registerEmail").value.trim();
const password = document.getElementById("registerPassword").value.trim();
// Validaciones básicas
if (!email || !password) {
showAlert("Por favor, completa todos los campos", "warning");
return;
}
if (password.length < 6) {
showAlert("La contraseña debe tener al menos 6 caracteres", "warning");
return;
}
try {
const result = await register(email, password);
setToken(result.token);
showAlert(`✅ Registro correcto! Bienvenido ${result.usuario.email}`, "success");
clearForms();
} catch (error) {
console.error("Error en registro:", error);
showAlert(error.message, "danger");
}
});
// Submit del formulario de login
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
const email = document.getElementById("loginEmail").value.trim();
const password = document.getElementById("loginPassword").value.trim();
if (!email || !password) {
showAlert("Por favor, completa todos los campos", "warning");
return;
}
try {
const result = await login(email, password);
setToken(result.token);
showAlert(`✅ Login correcto! Bienvenido ${result.usuario.email}`, "success");
clearForms();
} catch (error) {
console.error("Error en login:", error);
showAlert(error.message, "danger");
}
});
// Click en botón para obtener perfil protegido
btnGetProfile.addEventListener("click", async () => {
profileOutput.textContent = "Cargando...";
try {
const data = await getProtectedProfile();
profileOutput.textContent = JSON.stringify(data, null, 2);
showAlert("✅ Perfil protegido obtenido correctamente!", "success");
} catch (error) {
console.error("Error obteniendo perfil:", error);
showAlert(error.message, "danger");
profileOutput.textContent = `Error: ${error.message}`;
}
});
// Botón adicional para cargar credenciales de demo automáticamente
const testUserBtn = document.createElement('button');
testUserBtn.type = 'button'; // Importante: no debe submitir el formulario
testUserBtn.className = 'btn btn-outline-secondary btn-sm ms-2';
testUserBtn.textContent = 'Usar demo';
testUserBtn.addEventListener('click', () => {
document.getElementById('loginEmail').value = 'usuario@ejemplo.com';
document.getElementById('loginPassword').value = 'password123';
showAlert("Credenciales de demo cargadas. Haz click en 'Iniciar sesión'", "info");
});
// Añadir el botón al formulario de login
loginForm.querySelector('.mb-3:last-child').appendChild(testUserBtn);
}
// Inicialización básica del frontend cuando el DOM está listo
document.addEventListener('DOMContentLoaded', function() {
console.log("🚀 Frontend cargado completamente");
// Mostrar token actual (si existe)
renderToken();
// Configurar event listeners
setupEventHandlers();
// Mostrar información de ayuda al usuario
showAlert("¡Bienvenido! Usa 'usuario@ejemplo.com' con 'password123' para probar", "info");
});
Test-server.json
// test-server.js
// Script de testing y debugging para el servidor.
// Proporciona información detallada durante el arranque.
import { startServer } from './src/http/server.mjs';
import { initializeDatabase, obtenerTodosLosUsuarios } from './src/db/userDatabase.mjs';
console.log("🧪 INICIANDO SERVIDOR EN MODO DEBUG...\n");
// 1. Verificar e inicializar DB con logging extendido
console.log("1. 🔍 INICIALIZANDO BASE DE DATOS...");
initializeDatabase();
obtenerTodosLosUsuarios();
// 2. Iniciar servidor
console.log("\n2. 🚀 INICIANDO SERVIDOR...");
startServer();
// El servidor ahora está corriendo y listo para recibir requests
Instrucciones de ejecución
Ejecución
# Instalar dependencias (aunque no hay externas, esto inicializa el proyecto)
npm install
# O ejecutar normalmente
npm start
Probar la aplicación
- Abrir el navegador: Ve a
http://localhost:3000 - Usuario por defecto:
- Email:
usuario@ejemplo.com - Contraseña:
password123
- Email:
Características importantes del proyecto
- JWT manual: Implementación completa sin librerías externas
- Base de datos simple: Archivo JSON como almacenamiento
- Frontend completo: Interfaz con Bootstrap para testing
- Autenticación: Registro, login y ruta protegida
- Seguridad básica:
- Hash SHA-256 para contraseñas (en producción usar bcrypt)
- Validación de tokens JWT
- Manejo de expiración
- CORS: Configurado para desarrollo
Consideraciones para producción:
// En appConfig.mjs, cambiar por:
JWT_SECRET: process.env.JWT_SECRET || "clave_muy_segura_y_larga_en_produccion",
// Y usar bcrypt en lugar de SHA-256:
import bcrypt from 'bcrypt';
// password: await bcrypt.hash(password, 12)
El problema que resuelve JWT
Cuando un usuario se identifica (login correcto), el servidor necesita una forma de:
- Recordar quién es ese usuario en las siguientes peticiones.
- Saber si tiene permiso para acceder a ciertas rutas (perfil, datos privados, etc.).
- Hacerlo de forma eficiente y, a ser posible, sin tener que guardar estado en memoria del servidor para cada usuario (sesiones clásicas de “tabla en memoria”).
El enfoque clásico eran las sesiones de servidor:
- El servidor guarda en memoria o en BD una sesión asociada a un
sessionId. - El navegador guarda ese
sessionIden una cookie. - En cada petición, la cookie viaja, el servidor consulta la sesión y sabe quién eres.
JWT plantea otro enfoque: en lugar de guardar la sesión en el servidor, se guarda todo en un token firmado que viaja entre cliente y servidor.
¿Qué es exactamente un JWT?
Un JWT (JSON Web Token) es simplemente un texto con tres partes, separadas por puntos:
header.payload.signature
Cada parte está codificada en Base64 URL Safe:
-
Header: metadatos del token, por ejemplo:
{
"alg": "HS256",
"typ": "JWT"
} -
Payload: datos que nos interesan, por ejemplo:
{
"id": 1,
"email": "usuario@ejemplo.com",
"iat": 1732190000,
"exp": 1732193600
}Aquí
iates la fecha de emisión yexpla fecha de expiración. -
Signature: un hash calculado sobre
header.payloadusando una clave secreta del servidor.Es lo que garantiza que nadie puede modificar el token sin que el servidor lo detecte.
¿Qué hemos hecho en el flujo completo?
Registro / login: generación del JWT
En las rutas /api/register y /api/login del backend has hecho lo siguiente:
-
Verificación de identidad
En
register:-
Comprobamos que el email no existe y creamos un usuario nuevo.
En
login: Comprobamos que el email existe, hasheamos la contraseña recibida y la comparamos con la almacenada enusers.json.
Aquí es donde se realiza la autenticación clásica: email + contraseña → ¿correcto o no?
-
-
Creación del token JWT
Si las credenciales son válidas, llamas a:
const token = generarToken(usuario);Donde
usuariosolo expone{ id, email }.Dentro,
generarToken:-
Crea un
headerconalg: "HS256"ytyp: "JWT". -
Crea un
payloadcon:id,email(identidad del usuario),iat: ahora,exp: ahora + 1 hora (o lo que hayas definido).
-
Codifica header y payload.
-
Calcula la firma con
HS256y laJWT_SECRET. -
Devuelve un string tipo:
aaa.bbb.ccc.
-
-
Devolución del token al cliente
El backend responde con algo como:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9....",
"usuario": {
"id": 1,
"email": "usuario@ejemplo.com"
}
}Esto es la “emisión” del token, lo que en teoría se llama “issue a token”.
En resumen: el servidor ha comprobado quién eres y, si eres válido, te entrega un ticket firmado que dice: “esta persona es tal usuario y su sesión dura hasta tal momento”.
Almacenamiento del JWT en el cliente
En el frontend (app.js), cuando el registro o login tienen éxito:
-
Lees
data.token. -
Lo guardas en
localStorage:localStorage.setItem("jwt_demo_token", token); -
Lo muestras en pantalla para que el cliente vea físicamente el token.
Lo importante a nivel de teoría:
- El cliente ahora es responsable de guardar ese token.
- El servidor ya no mantiene una tabla de “sesiones”; confía en que, si recibe un token válido, es válido porque él mismo lo firmó.
Petición a la API protegida: envío del JWT
Cuando pulsas el botón “Obtener perfil protegido”, el frontend:
-
Recupera el token de
localStorage. -
Hace una petición
GET /api/profilecon la cabecera:Authorization: Bearer <token>
Eso es lo que en teoría se llama “Bearer token”: quien “porta” el token (bearer) se considera autorizado, siempre que el token sea válido. No se envían cookies ni sessionIds. Solo este header.
Verificación del JWT en el servidor
En el backend, la ruta /api/profile hace lo siguiente:
-
Lee el header
Authorization. -
Comprueba que comience por
Bearer. -
Extrae el token y llama a:
const userPayload = verificarToken(token);
Dentro de verificarToken(token) pasan tres cosas importantes:
- Revisión de formato: que tenga tres partes.
- Verificación de firma:
- Vuelve a calcular la firma esperada con la
JWT_SECRET. - La compara con la firma que viene en el token.
- Si no coinciden, alguien ha manipulado el payload → el token es inválido.
- Vuelve a calcular la firma esperada con la
- Verificación de expiración:
- Compara
expcon el tiempo actual. - Si
expestá en el pasado, el token se considera expirado.
- Compara
Si todo está bien, devuelve el payload:
{
id: 1,
email: "usuario@ejemplo.com",
iat: ...,
exp: ...
}
Con eso, la ruta /api/profile sabe: quién está haciendo la petición (id, email), que el token no ha sido modificado, y que no está caducado. Si es válido, responde 200 con los datos del usuario. Si no, responde 401: “Token inválido o faltante”.
Cómo encaja esto en la seguridad de APIs web
Teórico, pero muy práctico:
-
Autenticación
Se hace con
POST /api/loginoPOST /api/register, usando email y contraseña. El backend comprueba credenciales y decide si el usuario existe y es válido. -
Emisión del token
Si el login/registro es correcto, el backend emite un JWT firmado. Ese JWT representa la identidad del usuario y su sesión.
-
Cliente como portador del estado
El token se guarda en el frontend. En cada petición protegida, el cliente incluye el token. El servidor ya no guarda “estado de sesión” entre peticiones; confía en el token.
-
Autorización basada en token
Las rutas protegidas (
/api/profileen tu caso) verifican el token antes de devolver datos.Esto se puede extender fácilmente: añadir roles al payload (
role: "admin"), permisos específicos (scopes,permissions), etc. -
Caducidad (exp) como control de seguridad
El campo
expmarca la caducidad del token. Obliga a que, pasado un tiempo, el usuario tenga que relogear o renovar el token, lo que reduce el impacto en caso de robo del token.
Resumen conceptual de lo que has hecho
- Has pasado de un modelo de sesión en servidor a un modelo de token firmado.
- Has creado un módulo de JWT a bajo nivel que: construye el token siguiendo el estándar (header, payload, signature), firma el token con HMAC-SHA256, verifica firma y caducidad.
- Has conectado ese módulo con un backend sin frameworks: para emitir tokens en
/api/registery/api/login, para proteger/api/profilerevisando el headerAuthorization. - Has conectado todo con un frontend real que: hace login/registro, almacena el token, lo envía en las cabeceras de futuras peticiones.
Si lo miras de forma global, este ejercicio ya contiene el “esqueleto” de cualquier API moderna protegida con JWT: autenticación inicial, emisión del token, consumo de endpoints protegidos y verificación en cada request.
CORS y variables de entorno
CORS significa Cross-Origin Resource Sharing. Traducido literalmente: Compartir recursos entre orígenes distintos.
En la práctica, es un sistema de seguridad del navegador que decide si una página web puede hacer peticiones a un servidor que está en otro dominio.
Qué es un “origen” para el navegador
Un origen está compuesto por:
protocolo + dominio + puerto
Ejemplos de orígenes distintos:
Aunque compartan dominio, si cambian el puerto o el protocolo, ya son orígenes diferentes.
¿Por qué existe CORS?
Para proteger al usuario. Sin CORS, cualquier sitio web podría hacer lo siguiente:
- Abrir una página maliciosa sin que el usuario lo sepa.
- Esa página podría hacer peticiones a: tu API real, tu banco, tu gestor de archivos en la nube, tu servidor con sesiones activas.
- Y usaría automáticamente tus cookies o tokens guardados, porque el navegador las incluye.
Eso sería un ataque clásico llamado CSRF (Cross-Site Request Forgery).
CORS evita este escenario: el servidor tiene que autorizar explícitamente qué orígenes pueden pedirle datos.
Cómo funciona CORS en la práctica
Cuando un frontend intenta llamar a una API en otro origen, el navegador no envía la petición directamente. Antes hace una comprobación. Este proceso tiene dos formas:
1. Peticiones simples (simple requests)
Ocurren cuando: método GET, POST o HEAD, cabeceras simples (Accept, Content-Type con valores seguros), sin credenciales
En estos casos, el navegador:
- Envía la petición
- Mira la cabecera de respuesta del servidor
- Comprueba que exista:
Access-Control-Allow-Origin: https://mi-frontend.com
Si coincide, todo bien. Si no coincide, el navegador bloquea la respuesta antes de entregar los datos al JavaScript.
2. Preflight Requests (OPTIONS)
Cuando la petición es considerada “no simple”, por ejemplo:
- Método PUT, DELETE, PATCH, cabeceras personalizadas, uso de
Authorization: Bearer ...,envío de JSON complejo, uso decredentials: include
El navegador hace primero una petición especial:
OPTIONS /ruta
Ese es el preflight.
El objetivo es preguntar al servidor:
“¿Este origen tiene permiso para hacer esta operación?”
El servidor debe responder:
Access-Control-Allow-Origin: https://mi-frontend.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type, Authorization
Si alguna cabecera falta, el navegador bloquea la petición real.
Ejemplo real sencillo
Frontend en:
http://localhost:3000
Backend en:
http://localhost:4000
El frontend hace:
fetch("http://localhost:4000/api/profile");
El navegador detecta que los orígenes son distintos y comprueba si el backend le permite acceder.
Si el backend responde:
Access-Control-Allow-Origin: http://localhost:3000
La petición funciona.
Si responde:
Access-Control-Allow-Origin: *
También funciona, pero no debería usarse en APIs privadas.
Si no responde con ninguna cabecera CORS
El navegador bloquea la respuesta y verás errores como:
Access to fetch at 'http://localhost:4000/api/profile'
from origin 'http://localhost:3000'
has been blocked by CORS policy
¿Qué tiene que hacer el backend?
En Node.js puro, lo básico es:
res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
Y cuando llegue un preflight:
if (req.method === "OPTIONS") {
res.writeHead(200);
res.end();
return;
}
Con eso el servidor ya autoriza al frontend.
¿Y dónde entran las variables de entorno (ENV)?
No quieres tener esto escrito a mano en tu código:
Access-Control-Allow-Origin: http://localhost:3000
Porque: en desarrollo el frontend usa http://localhost:3000, en producción puede ser https://miweb.com, mañana quizá sea https://app.miweb.com
Por eso se suelen poner en variables de entorno:
CLIENT_ORIGIN=http://localhost:3000
Y en el backend:
const ORIGIN = process.env.CLIENT_ORIGIN;
res.setHeader("Access-Control-Allow-Origin", ORIGIN);
Así cambias el valor sin tocar el código.
Resumen
CORS no es un “problema”, ni un error: es un sistema de seguridad del navegador, obligatorio y necesario.
Sirve para:
- Evitar ataques que usan tu navegador para hacer peticiones con tus credenciales
- Obligar a los servidores a declarar explícitamente qué frontends pueden acceder
- Controlar métodos, cabeceras y credenciales permitidas
Si lo entiendes, configurar APIs se vuelve mucho más claro y predecible.
Cómo se configura CORS correctamente en Node.js puro
En Node.js puro, tú eres el framework. Eso significa que las cabeceras CORS las tienes que gestionar manualmente.
Las 3 cabeceras esenciales son:
Access-Control-Allow-Origin
Access-Control-Allow-Methods
Access-Control-Allow-Headers
Configuración mínima:
res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000");
Configuración completa recomendada:
res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
Y el preflight:
if (req.method === "OPTIONS") {
res.writeHead(200);
res.end();
return;
}
Sin este bloque, cualquier petición con JSON o Authorization fallará.
Cómo CORS interactúa con JWT
CORS no tiene nada que ver con autenticar a nadie, es importante entenderlo.
- CORS protege al usuario del navegador
- JWT protege al servidor y a los datos
¿Cómo interactúan?
Caso típico:
El frontend quiere llamar a una ruta protegida:
fetch("http://localhost:3000/api/profile", {
headers: {
Authorization: `Bearer ${token}`
}
});
Aquí pasan dos cosas:
1. El navegador detecta Authorization
Eso automáticamente fuerza un preflight OPTIONS.
2. El servidor debe autorizar esa cabecera:
Access-Control-Allow-Headers: Content-Type, Authorization
Si no lo hace, no importa si el JWT es válido: el navegador simplemente bloquea la petición antes de que llegue al backend.
Esto es un error muy habitual:
“Mi token es correcto, pero la API devuelve CORS error.”
La API ni siquiera ha recibido tu petición. El navegador la ha bloqueado antes.
Rutas públicas vs rutas privadas con CORS
Rutas públicas (no requieren JWT)
Solo necesitan:
Access-Control-Allow-Origin
Access-Control-Allow-Methods
Ejemplo:
if (url.pathname === "/public" && req.method === "GET") {
res.setHeader("Access-Control-Allow-Origin", ALLOW_ORIGIN);
res.end(JSON.stringify({ mensaje: "Recurso público" }));
}
Rutas privadas (requieren JWT)
Aquí debes permitir:
Authorization
Content-Type
Ejemplo:
if (url.pathname === "/api/profile" && req.method === "GET") {
res.setHeader("Access-Control-Allow-Origin", ALLOW_ORIGIN);
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
autenticarToken(req, res, () => {
res.end(JSON.stringify({ usuario: req.usuario }));
});
}
Si falta cualquiera de estas condiciones, el navegador bloqueará la petición.
Errores típicos y cómo evitarlos
Error 1: "No 'Access-Control-Allow-Origin' header present"
- Causa: El servidor no envió la cabecera Access-Control-Allow-Origin.
- Solución: Enviar siempre la cabecera en todas las rutas.
Error 2: Preflight OPTIONS sin manejar
Síntoma:
CORS preflight did not succeed
- Causa: El servidor no respondió a OPTIONS.
- Solución:
if (req.method === "OPTIONS") {
res.writeHead(200);
res.end();
return;
}
Error 3: Falta la cabecera Authorization en Allow-Headers
Síntoma:
Request header field authorization is not allowed by Access-Control-Allow-Headers
- Causa: El servidor no ha autorizado esa cabecera.
- Solución:
Access-Control-Allow-Headers: Content-Type, Authorization
Error 4: Intentar usar CORS sin entender qué origen se está enviando
El frontend envía su origen real:
Solución: usar variable de entorno:
CLIENT_ORIGIN=http://localhost:3000
Y en Node:
const ALLOW_ORIGIN = process.env.CLIENT_ORIGIN;
res.setHeader("Access-Control-Allow-Origin", ALLOW_ORIGIN);
Error 5: Intentar usar CORS para “arreglar” errores de backend
Si el backend está devolviendo un error 500, CORS no lo arregla.
Buenas prácticas profesionales con CORS
- No uses "*” en APIs privadas: Esto deja entrar a cualquier sitio web. Válido solo para APIs totalmente públicas.
- CORS siempre debe leerse desde env. Nunca lo pongas en duro en el código.
- El backend debe permitir solo lo necesario
Si tu API solo usa GET y POST:
Access-Control-Allow-Methods: GET, POST
Si solo necesitas Authorization y Content-Type:
Access-Control-Allow-Headers: Content-Type, Authorization
- No pongas credenciales en CORS si no las usas
Ejemplo incorrecto:
Access-Control-Allow-Credentials: true
Esto obliga a tener un origin exacto (no vale "*") y añade complejidad que no siempre necesitas.
Resumen final
CORS es un mecanismo del navegador para proteger al usuario, que solo se activa cuando hay orígenes diferentes, y que decide qué frontends pueden conectarse a tu backend.
- No autentica.
- No valida tokens.
- No cifra datos.
- Su misión es evitar que otros sitios web usen tu API sin permiso.
CORS con múltiples frontends permitidos
En muchos proyectos hay más de un frontend: una app React en localhost, una web pública, un panel de administración, una app móvil con WebView
Por tanto, ALLOW_ORIGIN no es uno solo.
Puedes tener:
CLIENT_ORIGIN_1=https://frontend.com
CLIENT_ORIGIN_2=https://admin-panel.com
En Node.js puro, la solución profesional es:
const ALLOWED_ORIGINS = [
process.env.CLIENT_ORIGIN_1,
process.env.CLIENT_ORIGIN_2
];
function applyCors(req, res) {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
}
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
}
¿Ves el detalle?
CORS permite reflejar el origen siempre y cuando ese origen esté en una lista interna aprobada.
Jamás se debe hacer: si la API es privada.
res.setHeader("Access-Control-Allow-Origin", "*");
CORS en desarrollo vs producción
Desarrollo:
Normalmente tienes:
- frontend en
http://localhost:3000 - backend en
http://localhost:4000
Esto obliga a usar CORS.
Producción:
Lo normal es que:
- frontend:
https://miweb.com - backend API:
https://api.miweb.com
Sigue habiendo orígenes diferentes, así que CORS sigue existiendo, pero ya no cambian los puertos.
Es fundamental que las variables de entorno diferencien ambos entornos:
.env.development
CLIENT_ORIGIN=http://localhost:3000
.env.production
CLIENT_ORIGIN=https://miweb.com
El código no cambia, solo la variable de entorno.
CORS con cookies, sesiones y SameSite
Aquí entra un concepto clave:
CORS con Authorization: Bearer es más fácil que CORS con cookies de sesión. Cuando usas cookies para sesiones en una API:
El navegador exige:
Access-Control-Allow-Credentials: true
Y NO puedes usar:
Access-Control-Allow-Origin: *
Debe ser un origen exacto.
Además, la cookie debe tener:
SameSite=None
Secure
Ejemplo correcto de peticiones fetch con cookies:
fetch("https://api.miweb.com/datos", {
credentials: "include"
});
Cabeceras necesarias:
Access-Control-Allow-Origin: https://miweb.com
Access-Control-Allow-Credentials: true
Problema común:
“No funciona la sesión entre frontend y backend aunque todo parece correcto.”
Respuesta: el navegador está bloqueando la cookie porque no tiene SameSite=None o Secure. Por eso JWT en headers suele ser más fácil: no depende del mecanismo de cookies CORS/CSRF.
APIs públicas vs APIs privadas
API pública: accesible desde cualquier sitio
Ejemplo: PokéAPI, Star Wars API, Rick & Morty API.
Config:
Access-Control-Allow-Origin: *
No necesitas Authorization
(no hay información sensible).
API privada: requiere autenticación
Nunca se usa *.
Solo orígenes autorizados:
Access-Control-Allow-Origin: https://miweb.com
Access-Control-Allow-Headers: Authorization, Content-Type
Patrones profesionales para organizar CORS
Una API profesional nunca mete CORS en todos los handlers manualmente.
Se usa una función centralizada.
En Node puro:
export function applyCors(req, res) {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
}
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
}
Y en el servidor:
applyCors(req, res);
if (req.method === "OPTIONS") {
res.writeHead(200);
res.end();
return;
}
Con esto, ni una sola ruta se queda sin CORS, porque todas pasan por la misma función.
Conclusión
Ya tienes una visión completa, tanto teórica como profesional, de cómo funciona CORS en APIs modernas:
- Es un sistema de seguridad del navegador.
- Controla quién puede hablar con tu API.
- Requiere respuestas explícitas del servidor.
- Afecta especialmente a APIs con JWT y Authorization.
- Es distinto en desarrollo, producción y detrás de proxies.
- Cookies y JWT se comportan diferente bajo CORS.
- Debe centralizarse en una función/middleware.
Diferencia real entre CORS y CSRF
Esto es una pregunta clásica que confunde incluso a programadores con experiencia.
CORS protege al usuario
Evita que cualquier web te haga peticiones automáticas a tus servicios usando tus cookies o tokens.
Ejemplos de ataque sin CORS:
- Visitas una web falsa
- Esa web intenta llamar al backend de tu banco usando tus cookies
- Sin CORS, podría mover dinero, acceder a datos, etc.
CORS dice:
Esto solo puede hacerlo el dominio X que yo autorice.
CSRF protege al servidor
CSRF es cuando una web maliciosa intenta que tú, sin saberlo, hagas una petición válida a otra web donde sí estás autenticado. Aunque parezcan parecidos, no resuelven el mismo problema.
Ejemplo:
- Estás logueado en tu panel de admin
- Abres una web maliciosa
- La web maliciosa hace un POST contra tu API
- Si tu API confía en cookies automáticamente → problema.
CORS solo previene parte del ataque, porque si el backend permite Access-Control-Allow-Origin:*… esa web maliciosa ya puede hacer la petición.
Por eso los sistemas robustos usan:
- CORS
- Tokens anti-CSRF
- Cabeceras personalizadas
SameSiteen cookies
JWT en headers generalmente resuelve la mayoría de problemas CSRF, porque la web atacante no tiene tu token.
¿Cómo hacer debugging profesional de CORS?
Los errores CORS son engañosos porque parecen venir del backend, pero en realidad provienen del navegador.
Aquí tienes un método profesional para diagnosticar:
Paso 1: ¿La petición real llega al backend?
En el servidor añade:
console.log("Petición recibida:", req.method, req.url);
Si no aparece nada en consola: el navegador ha bloqueado la petición antes de enviarla, por tanto es un error de CORS, no de backend
Paso 2: Comprobar el preflight
Abre DevTools → Network → Filtrar por OPTIONS
Si ves algo así:
OPTIONS api/profile
Status: (canceled)
o
Status: 200 (pero sin cabeceras CORS)
→ El preflight ha fallado.
Causas típicas:
- No manejar OPTIONS
- No autorizar Authorization
- No autorizar Content-Type
- No permitir métodos PUT, DELETE, PATCH
Paso 3: Revisar la respuesta del servidor
Si la respuesta no contiene: el navegador bloqueará la petición aunque el backend haya devuelto 200.
Access-Control-Allow-Origin
Access-Control-Allow-Methods
Access-Control-Allow-Headers
Paso 4: Revisar el “Origin” del navegador
Las peticiones desde React/Vite/etc. vienen desde:
Debe coincidir exactamente con:
Access-Control-Allow-Origin: valor
Error típico: aplicar "localhost:3000" sin el protocolo → no coincide.
CORS con fetch, axios, formularios e iframes
Fetch
Genera preflight en cuanto usas:
- Authorization
- JSON
- Métodos no simples
- Headers personalizados
Axios
Igual que fetch, pero muchas veces agrega headers automáticamente:
- X-Requested-With (genera preflight)
- Content-Type JSON
Por eso axios suele tener más preflights que fetch.
Formularios HTML <form>
Los forms clásicos NO activan CORS: usan application/x-www-form-urlencoded, no incluyen headers personalizados, no usan AJAX. Si un form apunta a otro dominio, el navegador permite el envío PERO bloquea la lectura de la respuesta si no hay CORS.
iframes
Las peticiones dentro del iframe no pasan por CORS. Pero comunicarse entre iframe y página sí necesita restricciones (postMessage).
CORS + proxies (Cloudflare, Vercel, Netlify, Nginx, Caddy)
Los proxies pueden reescribir cabeceras. Esto produce errores fantasma: tu backend envía CORS pero el proxy lo elimina.
Cloudflare
Cloudflare solo permite:
Access-Control-Allow-Origin: *
Si pones un dominio exacto, Cloudflare puede ignorarlo si usas “cache everything”.
Vercel
En Vercel los “serverless functions” permiten CORS pero debes manejar OPTIONS manualmente o devolver 200 siempre.
Netlify
Las funciones serverless requieren un fichero _headers para CORS:
/*
Access-Control-Allow-Origin: *
Nginx
Puede obligarte a configurar CORS allí en vez de en Node:
location / {
add_header Access-Control-Allow-Origin *;
}
Si se usa esta configuración, las cabeceras de CORS de Node quedan ignoradas.
CORS con WebSockets y APIs streaming
WebSockets no usan CORS
Los sockets no tienen CORS. La comprobación es mucho más sencilla, basada en handshake:
Origin: http://localhost:3000
Y tú decides si lo aceptas o no.
EventStream / Server-Sent Events
SSE sí requieren:
Access-Control-Allow-Origin
Pero no generan preflight.
Conclusión
Ya conoces cómo se usa CORS: dentro del navegador, dentro del backend, con JWT, con proxies, con cookies, con WebSockets, en depuración profesional. CORS es un mecanismo sencillo en fundamento, pero complejo en práctica porque depende de:
- El navegador
- El backend
- La configuración del proxy
- Las variables de entorno
- Cómo realizas la petición
- Si usas cookies o headers
- Si el origen cambia entre desarrollo y producción
Con lo que sabes ahora, puedes resolver cualquier error CORS real, tanto en Node nativo como detrás de infraestructura moderna.
CORS + JWT + cookies seguras
¿Dónde guardo el JWT: localStorage o cookies?
Tienes dos grandes opciones:
- JWT en localStorage (como en tu demo actual): El frontend controla el token y lo envía en
Authorization: Bearer .... Es sencillo de entender y de implementar. Vulnerable si te “cuelan” XSS, porque JS puede leer el token. - JWT en cookie (lo que quieres mezclar ahora):
- El servidor mete el JWT en una cookie.
- Esa cookie puede ser
HttpOnly(JS no la puede leer). - El navegador la envía automáticamente en cada petición al mismo dominio/origen.
- Es más segura frente a XSS, pero ahora hay que pensar en CORS + CSRF.
Tú ya tienes montado el flujo JWT en header. Lo que vamos a ver ahora es el escenario: JWT en cookie + CORS bien configurado.
¿Qué cambia al usar cookies con JWT?
Con JWT en header:
Authorization: Bearer <token>
El frontend lo pone explícitamente. CORS solo debe permitir la cabecera Authorization. Con JWT en cookie:
-
El servidor devuelve algo como:
Set-Cookie: access_token=JWT...; HttpOnly; Secure; SameSite=None -
El navegador, a partir de ese momento, enviará:
Cookie: access_token=JWT...en cada petición al mismo dominio/origen (y, si SameSite lo permite, también en cross-site).
Y ojo: en peticiones fetch, si quieres que se envíen cookies cuando el origen es distinto, tienes que hacer:
fetch("https://api.miapp.com/profile", {
method: "GET",
credentials: "include"
});
Si no pones credentials: "include", la cookie no viaja en cross-origin, aunque exista. Así que al pasar JWT a cookie, cambian dos cosas: se añade el parámetro credentials en el fetch/axios, CORS debe permitir credenciales.
CORS cuando hay cookies
Cuando usas cookies en cross-origin, hay dos reglas clave:
-
Debes enviar:
Access-Control-Allow-Origin: https://mi-frontend.com
Access-Control-Allow-Credentials: true -
No puedes usar:
Access-Control-Allow-Origin: *Si
Allow-Credentialsestrue, el origen tiene que ser concreto.
Es decir, el backend debe devolver algo así:
res.setHeader("Access-Control-Allow-Origin", "https://mi-frontend.com");
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
Y el frontend:
fetch("https://api.miapp.com/profile", {
method: "GET",
credentials: "include"
});
Si cualquiera de estas piezas falta: el navegador no envía la cookie, o el navegador bloquea la respuesta por CORS.
Flags importantes en las cookies: HttpOnly, Secure, SameSite
HttpOnly
Si se pone HttpOnly, JavaScript en el navegador no puede leer la cookie. Eso protege el token contra XSS (si te inyectan JS, no pueden robar el JWT). Para un JWT en cookie, lo normal es:
Set-Cookie: access_token=jwt...; HttpOnly; ...
Secure
Obliga a que la cookie solo se envíe por HTTPS. En producción, siempre. En desarrollo, a menudo no tienes HTTPS, así que: o no usas Secure mientras desarrollas, o montas HTTPS local.
SameSite
Define cuándo la cookie se envía en peticiones cross-site (desde otra web/origen).
Valores:
Lax(por defecto en muchos navegadores modernos): La cookie se envía en la mayoría de navegaciones, pero restringe algunos casos CSRF.Strict: Solo se envía si la navegación viene del mismo sitio. Muy restrictivo.None: La cookie se envía siempre, incluso en cross-site, pero obliga a usarSecure.
Si tienes:
- frontend en
https://app.miweb.com - backend en
https://api.miweb.com
Para que la cookie viaje cuando llamas a la API desde el frontend, muchas veces necesitas:
Set-Cookie: access_token=jwt...; HttpOnly; Secure; SameSite=None
Si usas Lax y todo está bajo el mismo dominio base, puede funcionar, pero None es lo más flexible para APIs separadas.
Encajando todo: flujo real CORS + JWT + cookies
Te hago un mini-flujo conceptual (sin mucho código) para que se vea claro.
1. Login
El frontend hace:
fetch("https://api.miapp.com/login", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ email, password })
});
El backend:
-
Valida credenciales.
-
Genera el JWT (como ya tienes).
-
Lo mete en una cookie:
res.setHeader("Set-Cookie",
`access_token=${token}; HttpOnly; Secure; SameSite=None; Path=/`
); -
Responde con:
res.setHeader("Access-Control-Allow-Origin", "https://app.miweb.com");
res.setHeader("Access-Control-Allow-Credentials", "true");
// ... resto CORS
La respuesta va al navegador, que guarda la cookie.
2. Llamada a ruta protegida
El frontend:
fetch("https://api.miapp.com/profile", {
method: "GET",
credentials: "include"
});
- No necesita añadir Authorization.
- La cookie
access_token=...va sola.
El backend:
- Lee la cookie del header
Cookie: .... - Extrae
access_token. - Llama a tu
verificarToken(access_token). - Si es válido → responde 200 con los datos.
- Si es inválido → responde 401/403.
Y, otra vez, debe devolver:
res.setHeader("Access-Control-Allow-Origin", "https://app.miweb.com");
res.setHeader("Access-Control-Allow-Credentials", "true");
para que el navegador no bloquee la respuesta.
¿Qué es más recomendable: JWT en header o en cookie?
Depende de lo que priorices.
JWT en header (como la demo de más arriba)
Ventajas:
- Muy fácil de entender: el token va explícito en Authorization.
- CORS es más simple: no necesitas
Allow-Credentials. - No dependes de las reglas de cookies del navegador.
Inconvenientes:
- Si tienes un XSS, pueden robar el token de localStorage.
JWT en cookie segura (HttpOnly)
Ventajas:
- JS no puede leer el token → mucho más resistente a XSS.
- La API puede “parecer” más cercana al modelo sesión tradicional.
Inconvenientes:
- Hay que configurar CORS con
Allow-Credentialsy origen exacto. - Necesitas entender
SameSiteySecure. - Tienes que pensar en CSRF si permites que otras webs puedan disparar peticiones que incluyan tus cookies (aunque con APIs tipo SPA, se suele combinar con otros mecanismos).
Resumen mental rápido
Cuando integras CORS + JWT + cookies seguras estás haciendo esto:
- JWT ya no va en Authorization, va en cookie.
- El backend firma el JWT como siempre, pero lo manda con
Set-Cookie(HttpOnly,Secure,SameSite). - El frontend, para que la cookie viaje en cross-origin, usa
credentials: "include". - CORS debe: admitir el origen concreto (
Access-Control-Allow-Origin: https://mi-frontend.com), permitir credenciales (Access-Control-Allow-Credentials: true). - En cada petición protegida, el backend verifica el JWT que viene en la cookie, exactamente igual que antes verificabas el de Authorization.