La SPA de Tienda Aroma vive en https://tiendaaroma.example. La API vive en https://api.tiendaaroma.example. La primera vez que alguien escribe un fetch desde la SPA a la API, la consola del navegador muestra el mensaje más famoso del desarrollo web:
Access to fetch at 'https://api.tiendaaroma.example/v1/cafes' from origin 'https://tiendaaroma.example' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Y entonces ocurre lo peor: alguien busca en Internet, encuentra app.use(cors()) sin argumentos, lo pega, funciona, y acaba de abrir su API a cualquier página web del mundo. Esta lección existe para que eso no pase. Vamos a entender por qué el navegador bloquea, qué protege exactamente, por qué curl y Aroma Móvil no ven ninguno de estos problemas, y cómo se configura una política que deja pasar a la SPA y al panel sin abrir la puerta a nadie más. Al terminar, src/config/cors.js existirá y el middleware cors ocupará la posición 4 de la cadena de src/app.js —la posición importa, y veremos por qué.
Advertencia. CORS es un mecanismo del navegador y no es un control de acceso del servidor. Una configuración de CORS bien hecha no sustituye a la autenticación ni a la autorización. Cualquier despliegue real debe revisarlo un profesional de seguridad. Todos los dominios y datos de esta lección son ficticios.
Contenido
- La política del mismo origen
- Qué es exactamente un origen
- Qué protege realmente la política del mismo origen
- Por qué
curly Aroma Móvil no se ven afectados - Peticiones simples y peticiones con preflight
- El intercambio
OPTIONScompleto - Todas las cabeceras de CORS
Access-Control-Expose-Headers: la que siempre falta- La configuración real de Tienda Aroma
- Por qué
*yAllow-Credentialsson incompatibles - La posición en la cadena de
src/app.js - Errores típicos y cómo leerlos en la consola
- Cookies entre orígenes:
SameSite,Secure,HttpOnly - CSRF: qué es y por qué Bearer es inmune
- Otras políticas del navegador
- CORS no autoriza nada
- Probar CORS con
curly con las DevTools
- La política del mismo origen
La política del mismo origen (Same-Origin Policy, SOP) es la regla de seguridad fundamental de los navegadores: el código JavaScript de una página solo puede leer respuestas de su propio origen.
Es lo que impide esto:
// Este script está en https://sitio-malicioso.example, que has abierto sin darte cuenta.
const r = await fetch('https://mi-banco.example/api/cuentas', { credentials: 'include' });
const datos = await r.json(); // ← la SOP lo impide
enviarAlAtacante(datos);Sin la SOP, cualquier página que visitaras podría, en segundo plano, leer tu correo, tu banco y tu intranet aprovechando las sesiones que ya tienes abiertas. La web sería inutilizable.
Y ahora la parte incómoda: la SOP también bloquea tu propia SPA llamando a tu propia API, porque el navegador no tiene forma de saber que tiendaaroma.example y api.tiendaaroma.example son la misma organización.
CORS (Cross-Origin Resource Sharing) es el mecanismo estandarizado por el que el servidor dice al navegador: «este origen concreto sí puede leer mis respuestas». Es decir:
CORS no es una defensa. Es una relajación controlada de una defensa que ya existe.
Interiorizar esa frase evita el 90 % de los errores conceptuales sobre CORS.
- Qué es exactamente un origen
Un origen es la tripleta esquema + host + puerto. Los tres tienen que coincidir, exactamente, sin excepciones.
Tomando https://tiendaaroma.example como referencia:
| URL | ¿Mismo origen? | Por qué |
|---|---|---|
https://tiendaaroma.example/carrito |
Sí | La ruta no forma parte del origen |
https://tiendaaroma.example:443/ |
Sí | 443 es el puerto por defecto de HTTPS |
http://tiendaaroma.example |
No | Esquema distinto |
https://api.tiendaaroma.example |
No | Host distinto (subdominio ≠ mismo host) |
https://www.tiendaaroma.example |
No | Host distinto |
https://tiendaaroma.example:8443 |
No | Puerto distinto |
https://tiendaaroma.example.evil.example |
No | Otro dominio que solo empieza igual |
Dos consecuencias prácticas:
Un subdominio es otro origen. Esta es la fuente número uno de sorpresa: mucha gente asume que api.tiendaaroma.example es "lo mismo" que tiendaaroma.example. Para el navegador no lo es, y por eso Tienda Aroma necesita CORS.
La última fila importa para la seguridad. Al validar orígenes, origen.startsWith('https://tiendaaroma.example') acepta https://tiendaaroma.example.evil.example, un dominio que cualquiera puede registrar. La comparación tiene que ser de igualdad exacta contra una lista blanca.
- Qué protege realmente la política del mismo origen
Aquí hay una sutileza que casi todo el mundo entiende mal al principio, y que cambia por completo cómo se razona sobre CORS.
La petición se envía. Cuando tu página hace fetch a otro origen sin preflight, el navegador envía la petición al servidor y el servidor la procesa. Lo que la SOP bloquea es que el JavaScript de la página lea la respuesta.
sequenceDiagram
participant JS as JS en sitio-malicioso.example
participant N as Navegador
participant API as api.tiendaaroma.example
JS->>N: fetch('https://api.tiendaaroma.example/v1/cafes')
N->>API: GET /v1/cafes (la peticion SI se envia)
API->>API: La procesa: consulta la base de datos
API->>N: 200 con los datos (sin Access-Control-Allow-Origin)
N->>N: Falta la cabecera: NO entrego la respuesta
N->>JS: TypeError: Failed to fetch (sin detalles)
Consecuencias que hay que tener muy claras:
- Si el endpoint tiene efectos secundarios, ocurren igualmente. Un
GETque borrase algo lo borraría, aunque el atacante no viera la respuesta. Es un argumento más para la safety delGETde 02-03. - CORS no protege tu servidor de nada. Protege los datos del usuario impidiendo que otra página los lea con las credenciales de ese usuario.
- El atacante no ve el error. El JavaScript recibe un
TypeError: Failed to fetchgenérico, sin código de estado ni cuerpo. Es deliberado: si viera el401o el404, ya tendría información.
Las peticiones con preflight (apartado 5) sí se detienen antes de enviarse, y ahí sí hay una protección real del servidor: por eso Content-Type: application/json y Authorization disparan preflight.
- Por qué
curl y Aroma Móvil no se ven afectados
curl y Aroma Móvil no se ven afectados# Esto funciona perfectamente. Siempre. Con cualquier API del mundo.
curl -s https://api.tiendaaroma.example/v1/cafescurl no aplica CORS. Ni Postman, ni un script de Node, ni el backend de CataBox, ni la app Aroma Móvil (que hace peticiones nativas, no desde un contexto de navegador). CORS lo implementa el navegador, y solo el navegador, porque es el único que ejecuta código de terceros con las credenciales del usuario.
| Consumidor | ¿Aplica CORS? | Por qué |
|---|---|---|
| SPA de la tienda | Sí | Es JavaScript en un navegador |
| Panel interno | Sí | Ídem |
| Aroma Móvil (nativa) | No | No hay contexto de origen |
| Aroma Móvil (WebView) | Sí | Es un navegador embebido |
| Backend de CataBox | No | Servidor a servidor |
| RápidoEnvíos | No | Ídem |
curl, Postman |
No | No son navegadores |
De ahí se sigue la conclusión más importante de la lección, que hay que repetir hasta que resulte obvia:
Una política de CORS restrictiva no impide a nadie llamar a tu API. Solo impide que una página web de otro origen lea la respuesta desde el navegador de un usuario. La protección real de tus datos es, siempre, la autenticación y la autorización del servidor.
- Peticiones simples y peticiones con preflight
El navegador distingue dos categorías, y la diferencia es visible en el rendimiento y en los errores.
Peticiones simples
Se envían directamente, sin consulta previa. Deben cumplir todas estas condiciones:
- Método
GET,HEADoPOST. - Cabeceras limitadas a las «seguras de lista»:
Accept,Accept-Language,Content-Language,Content-Type(con restricciones) y algunas más. Content-Typesolo puede serapplication/x-www-form-urlencoded,multipart/form-dataotext/plain.- Sin manejadores de eventos de subida en el
XMLHttpRequest.
La razón histórica de esta lista: son exactamente las peticiones que un formulario HTML podía hacer antes de que existiera CORS. Como ya eran posibles, exigir permiso previo no habría añadido seguridad y sí habría roto la web.
Peticiones con preflight
Cualquier cosa fuera de esa lista dispara una petición previa OPTIONS. En Tienda Aroma, eso significa casi todas:
| Petición | ¿Preflight? | Por qué |
|---|---|---|
GET /v1/cafes sin cabeceras |
No | Simple |
GET /v1/cafes con Authorization |
Sí | Cabecera no permitida en simples |
POST /v1/pedidos con Content-Type: application/json |
Sí | Ese Content-Type no está en la lista |
POST con Content-Type: text/plain |
No | Simple |
PUT, PATCH, DELETE |
Sí | Método no permitido en simples |
Cualquiera con Aroma-Traza-Id |
Sí | Cabecera personalizada |
Como nuestra API usa Authorization: Bearer y application/json en todas partes, prácticamente todo el tráfico de la SPA lleva preflight. Por eso Access-Control-Max-Age (apartado 7) tiene un impacto real en el rendimiento: sin caché de preflight, cada petición se convierte en dos.
- El intercambio
OPTIONS completo
OPTIONS completoVeamos, en crudo, lo que ocurre cuando la SPA crea un pedido.
sequenceDiagram participant JS as SPA (tiendaaroma.example) participant N as Navegador participant API as api.tiendaaroma.example JS->>N: fetch POST /v1/pedidos con Authorization y JSON Note over N: No es simple: hace falta preflight N->>API: OPTIONS /v1/pedidos + Origin + Access-Control-Request-* API->>N: 204 + Allow-Origin, Allow-Methods, Allow-Headers, Max-Age Note over N: Permitido. Cachea la respuesta 10 min N->>API: POST /v1/pedidos (la peticion real) API->>N: 201 + Allow-Origin + Expose-Headers + Location N->>JS: Respuesta entregada, incluida la cabecera Location
Paso 1: el preflight. Lo genera el navegador solo; tu código nunca lo escribe:
OPTIONS /v1/pedidos HTTP/1.1
Host: api.tiendaaroma.example
Origin: https://tiendaaroma.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type,idempotency-keyFíjate en tres cosas: no lleva cuerpo, no lleva el Authorization real (solo anuncia que lo va a mandar) y las cabeceras anunciadas van en minúsculas y ordenadas.
Paso 2: la respuesta al preflight.
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://tiendaaroma.example
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE
Access-Control-Allow-Headers: Authorization,Content-Type,Idempotency-Key,Aroma-Traza-Id
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: OriginPaso 3: la petición real. Solo si el paso 2 lo permitió:
POST /v1/pedidos HTTP/1.1
Host: api.tiendaaroma.example
Origin: https://tiendaaroma.example
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json
Idempotency-Key: 3f2b9a10-7c4e-4b6a-9d21-0a5e1f8c7b33
{"clienteId":"cli_842","lineas":[{"cafeId":"caf_001","cantidad":2}]}Paso 4: la respuesta real. Y aquí está el punto que se olvida siempre: la respuesta real también necesita Access-Control-Allow-Origin. Que el preflight haya ido bien no basta.
HTTP/1.1 201 Created
Access-Control-Allow-Origin: https://tiendaaroma.example
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: Location,Link,Aroma-Traza-Id,Aroma-RateLimit-Limite,Aroma-RateLimit-Restantes,Aroma-RateLimit-Reinicio,Retry-After
Location: /v1/pedidos/ped_5002
Vary: Origin
Content-Type: application/json
{"id":"ped_5002","estado":"pendiente_pago", ...}Sin Expose-Headers, la SPA recibiría el 201 y el cuerpo, pero respuesta.headers.get('Location') devolvería null.
- Todas las cabeceras de CORS
Que envía el navegador
| Cabecera | Cuándo | Contenido |
|---|---|---|
Origin |
Toda petición entre orígenes | El origen de la página. No se puede falsear desde JavaScript |
Access-Control-Request-Method |
Solo preflight | Método que se va a usar |
Access-Control-Request-Headers |
Solo preflight | Cabeceras que se van a enviar |
Que Origin no sea falsificable desde JavaScript es lo que hace que el mecanismo funcione: el navegador la escribe y no deja al script tocarla. Ahora bien, un cliente que no sea un navegador puede escribir lo que quiera (curl -H "Origin: ..."), y por eso Origin no sirve como control de acceso.
Que responde el servidor
| Cabecera | Valores | Qué hace |
|---|---|---|
Access-Control-Allow-Origin |
Un origen concreto o * |
Quién puede leer la respuesta. Un solo valor, nunca una lista |
Access-Control-Allow-Methods |
GET,POST,PUT,... |
Métodos permitidos (solo en preflight) |
Access-Control-Allow-Headers |
Lista de cabeceras | Cabeceras que el cliente puede enviar (solo en preflight) |
Access-Control-Expose-Headers |
Lista de cabeceras | Cabeceras de respuesta que el JS puede leer |
Access-Control-Allow-Credentials |
true |
Permite enviar cookies y leer la respuesta con credenciales |
Access-Control-Max-Age |
Segundos | Cuánto cachear el preflight |
Vary |
Origin |
Imprescindible: ver abajo |
Dos detalles que causan bugs:
Allow-Origin admite un único valor. No existe Access-Control-Allow-Origin: https://a.example, https://b.example. Para varios orígenes, el servidor refleja el origen recibido si está en su lista blanca. Eso es lo que hace el paquete cors con una función origin.
Max-Age tiene topes del navegador. Aunque pidas 86.400 segundos, los navegadores imponen su propio máximo (del orden de dos horas en Chromium, menos en otros). Un valor de 600 es un buen equilibrio: reduce mucho los preflights y no deja un cambio de política congelado demasiado tiempo.
Vary: Origin
Si el servidor refleja el origen, la respuesta depende de la cabecera Origin. Sin Vary: Origin, cualquier caché intermedia —una CDN, un proxy corporativo, la caché del navegador— puede guardar la respuesta para https://tiendaaroma.example y servírsela a una petición de https://panel.tiendaaroma.example, que entonces recibirá un Allow-Origin equivocado y fallará de forma intermitente e inexplicable.
Es la misma mecánica de Vary que vimos en 02-05 con Accept-Language, y la retomaremos en 04-06 al hablar de caché. Los tres casos son el mismo principio: si la respuesta depende de una cabecera de la petición, dilo en Vary.
Access-Control-Expose-Headers: la que siempre falta
Access-Control-Expose-Headers: la que siempre faltaPor defecto, el JavaScript de otro origen solo puede leer siete cabeceras de respuesta, las llamadas «seguras de lista»:
Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma.
Todas las demás son invisibles, aunque estén ahí. Y eso arrasa con buena parte de lo que hemos construido en este curso:
| Cabecera | Para qué la necesita la SPA | Sin exponer |
|---|---|---|
Location |
Saber la URI del pedido recién creado (03-03) | null |
Link |
Paginación RFC 8288 (02-06) | La SPA no puede paginar |
ETag |
Peticiones condicionales y If-Match (04-06) |
Sin caché ni concurrencia optimista |
Retry-After |
Saber cuánto esperar tras un 429 (04-04) |
Reintentos a ciegas |
Aroma-RateLimit-* |
Frenar antes de chocar (04-04) | Mecanismo inútil |
Aroma-Traza-Id |
Mostrar el identificador en un mensaje de error (04-07) | Soporte a ciegas |
Allow |
Saber qué métodos admite tras un 405 |
Invisible |
Accept-Patch |
Saber qué formato de parche acepta (02-05) | Invisible |
Este es el eslabón que unía las tres lecciones anteriores. Tienda Aroma expone exactamente esto:
Access-Control-Expose-Headers: Location, Link, ETag, Accept-Patch, Allow, Retry-After, Aroma-Traza-Id, Aroma-RateLimit-Limite, Aroma-RateLimit-Restantes, Aroma-RateLimit-Reinicio
Y un criterio de diseño: se expone lo necesario, no todo. Existe el comodín * para Expose-Headers, pero no funciona cuando hay credenciales y, sobre todo, exponer cabeceras de infraestructura filtra información innecesaria.
- La configuración real de Tienda Aroma
// src/config/cors.js (fichero NUEVO)
import { entorno } from './entorno.js';
/**
* Orígenes permitidos, por entorno, leídos de la configuración.
*
* En .env:
* ORIGENES_PERMITIDOS=https://tiendaaroma.example,https://panel.tiendaaroma.example
*
* En desarrollo se añaden los locales para que la SPA en Vite funcione.
*/
function origenesPermitidos() {
const configurados = (entorno.ORIGENES_PERMITIDOS ?? '')
.split(',')
.map((o) => o.trim())
.filter(Boolean);
if (entorno.NODE_ENV === 'desarrollo') {
return [...configurados, 'http://localhost:5173', 'http://localhost:4173'];
}
return configurados;
}
const PERMITIDOS = new Set(origenesPermitidos());
export const opcionesCors = {
/**
* Función de decisión. El paquete `cors` la llama con el valor de Origin.
* - callback(null, true) → refleja ese origen en Access-Control-Allow-Origin
* - callback(null, false) → NO emite la cabecera: el navegador bloqueará
*
* IMPORTANTE: no se llama a callback(error). Devolver un error convertiría el
* preflight en un 500 y el mensaje de la consola sería aún más confuso. Se
* responde sin la cabecera y que el navegador aplique su política.
*/
origin(origen, callback) {
// Sin Origin: curl, Postman, Aroma Móvil, servidor a servidor. Se permite:
// esas peticiones no las protege CORS, las protege la autenticación.
if (!origen) return callback(null, true);
// Comparación por IGUALDAD EXACTA contra la lista blanca.
return callback(null, PERMITIDOS.has(origen));
},
// Métodos que la API admite. OPTIONS lo gestiona el propio paquete.
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
// Cabeceras que el cliente puede ENVIAR.
allowedHeaders: [
'Authorization',
'Content-Type',
'Accept',
'Accept-Language',
'If-Match', // concurrencia optimista (04-06)
'If-None-Match', // caché condicional (04-06)
'Idempotency-Key', // 02-03 / 03-03
'Aroma-Traza-Id', // el cliente puede propagar su propia traza (04-07)
],
// Cabeceras que el cliente puede LEER. Sin esto, son invisibles.
exposedHeaders: [
'Location',
'Link',
'ETag',
'Accept-Patch',
'Allow',
'Retry-After',
'Aroma-Traza-Id',
'Aroma-RateLimit-Limite',
'Aroma-RateLimit-Restantes',
'Aroma-RateLimit-Reinicio',
],
// Tienda Aroma usa Authorization: Bearer, NO cookies. Ver apartado 13.
credentials: false,
// El preflight se cachea 10 minutos: reduce a la mitad las idas y vueltas.
maxAge: 600,
// Responder el preflight con 204 y cortar ahí.
optionsSuccessStatus: 204,
preflightContinue: false,
};# .env.example (MODIFICADO) ORIGENES_PERMITIDOS=https://tiendaaroma.example,https://panel.tiendaaroma.example
Y la validación en el arranque, coherente con 03-01: si en producción no hay orígenes configurados, el proceso no debe arrancar, porque la alternativa silenciosa es peor.
// src/config/entorno.js (MODIFICADO, fragmento)
ORIGENES_PERMITIDOS: z
.string()
.min(1, 'ORIGENES_PERMITIDOS es obligatorio')
.refine(
(v) => v.split(',').every((o) => o.trim().startsWith('https://') || o.includes('localhost')),
'Todos los orígenes permitidos deben usar https (salvo localhost en desarrollo)'
),
- Por qué
* y Allow-Credentials son incompatibles
* y Allow-Credentials son incompatiblesAccess-Control-Allow-Origin: * significa «cualquier página web del mundo puede leer esta respuesta». Hay un caso en el que es perfectamente razonable:
| Escenario | * aceptable |
Por qué |
|---|---|---|
GET /v1/cafes público sin autenticación |
Sí | Es el escaparate; la información ya es pública |
| Cualquier endpoint que devuelva datos de un usuario | No | Depende de quién pregunta |
Cualquier cosa con Authorization o cookies |
No | Y además está prohibido por el estándar |
La prohibición es explícita en la especificación: si Access-Control-Allow-Credentials: true, entonces Access-Control-Allow-Origin no puede ser *. Tiene que ser un origen concreto.
# ❌ El navegador RECHAZA esta combinación y bloquea la respuesta.
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: trueEl motivo es claro cuando se piensa en el ataque que evita: si se permitiera, cualquier página web podría hacer peticiones con las cookies de sesión del usuario a cualquier API y leer el resultado. Sería la eliminación completa de la política del mismo origen. El comodín solo puede aplicarse a datos que no dependen de quién pregunta, y en cuanto hay credenciales, dependen.
Un matiz que confunde: credentials: 'include' en el fetch afecta a cookies y cabeceras de autenticación HTTP gestionadas por el navegador, no a un Authorization: Bearer que tú pones a mano. Aun así, con Bearer se sigue necesitando una lista blanca, porque el token identifica al usuario y la respuesta depende de él.
Decisión de Tienda Aroma: lista blanca en toda la API. Se podría hacer una excepción con * en GET /v1/cafes, que es público y cacheable, pero mantener una sola política es más simple, menos frágil y coherente con el principio de consistencia de 04-01.
- La posición en la cadena de
src/app.js
src/app.js// src/app.js (extracto tras 04-05)
import express from 'express';
import cors from 'cors';
import { rutasV1 } from './rutas/index.js';
import { asignarTrazaId } from './middleware/traza.js';
import { cabecerasSeguridad } from './middleware/seguridad.js';
import { opcionesCors } from './config/cors.js'; // ← NUEVO
import { limiteGlobal } from './middleware/limite-peticiones.js';
import { manejadorNoEncontrado } from './middleware/no-encontrado.js';
import { manejadorErrores } from './middleware/errores.js';
export const app = express();
app.disable('x-powered-by'); // 1
app.set('trust proxy', 1); // (04-04)
app.use(asignarTrazaId); // 2
app.use(cabecerasSeguridad); // 3 helmet (04-02)
app.use(cors(opcionesCors)); // 4 ← NUEVO
// (5) registro estructurado → 04-07
app.use(limiteGlobal); // 6 (04-04)
app.use(express.json({ limit: '100kb', /* ... */ })); // 7
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
app.get('/salud', (req, res) => res.json({ estado: 'ok' })); // 8
app.use('/v1', rutasV1); // 9
app.use(manejadorNoEncontrado); // 10
app.use(manejadorErrores); // 11Por qué la posición 4 y no otra. Es la decisión más importante de esta lección:
- Antes del rate limiting (6): si no, un
429sale sin cabeceras de CORS y el navegador lo convierte en un error de red opaco. El desarrollador de la SPA ve «Failed to fetch» y no tiene ni idea de que ha superado un límite. - Antes del parser de JSON (7): el preflight
OPTIONSno lleva cuerpo, pero unPOSTcon JSON mal formado produciría un400sin cabeceras CORS, y otra vez la SPA vería un error inútil. - Antes de las rutas (9) y, por tanto, antes de toda autenticación: esta es la clave del error clásico del apartado 12. El preflight
OPTIONSno llevaAuthorization—el navegador no lo envía nunca—, así que si el middleware de autenticación se ejecutara antes, respondería401al preflight y la petición real nunca se enviaría. - Después de helmet (3): para que la respuesta al preflight lleve también las cabeceras de seguridad y para que, si helmet y CORS entran en conflicto (el caso de
Cross-Origin-Resource-Policyque vimos en 04-02), CORS tenga la última palabra sobre sus propias cabeceras. - Después de la traza (2): para poder correlacionar un preflight fallido en los logs.
Y una consecuencia de diseño que conviene notar: el paquete cors responde al OPTIONS y termina la cadena (preflightContinue: false). El preflight no llega a las rutas, así que el router.all(...) con metodoNoPermitido(...) de cada ruta no interfiere con él.
- Errores típicos y cómo leerlos en la consola
| Mensaje en la consola | Causa real | Solución |
|---|---|---|
No 'Access-Control-Allow-Origin' header is present |
El origen no está en la lista blanca, o el middleware no se ejecutó | Añadir el origen a ORIGENES_PERMITIDOS; comprobar que cors está antes de lo que responde |
The 'Access-Control-Allow-Origin' header has a value 'https://otro.example' that is not equal to the supplied origin |
Una caché intermedia devolvió la respuesta de otro origen | Añadir Vary: Origin |
Response to preflight request doesn't pass access control check |
El OPTIONS no devolvió 2xx, o le faltan cabeceras |
Ver el OPTIONS en la pestaña de red; suele ser un 401 (ver abajo) |
Method PATCH is not allowed by Access-Control-Allow-Methods |
Falta el método en methods |
Añadirlo a opcionesCors.methods |
Request header field idempotency-key is not allowed by Access-Control-Allow-Headers |
Cabecera no declarada | Añadirla a allowedHeaders |
Credentials flag is 'true', but 'Access-Control-Allow-Origin' is '*' |
Combinación prohibida | Lista blanca en vez de * |
La respuesta llega pero headers.get('Link') es null |
Falta Access-Control-Expose-Headers |
Añadir la cabecera a exposedHeaders |
TypeError: Failed to fetch sin más detalle |
Puede ser CORS, red, DNS o certificado | Mirar la pestaña de red, no la consola |
El clásico: el preflight devuelve 401
Access to fetch at 'https://api.tiendaaroma.example/v1/pedidos' from origin 'https://tiendaaroma.example' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: It does not have HTTP ok status.
Traducción: el OPTIONS devolvió 401. Y devolvió 401 porque el middleware de autenticación se ejecutó antes que el de CORS, y el preflight no lleva Authorization: el navegador nunca lo incluye, por diseño, porque el preflight es una consulta sobre la política, no una petición de datos.
// ❌ El preflight muere en autenticar y nunca llega a cors.
app.use(autenticar);
app.use(cors(opcionesCors));
// ✅ CORS primero. El preflight se responde con 204 y ni siquiera llega a las rutas.
app.use(cors(opcionesCors));
app.use('/v1', rutasV1); // dentro de cada ruta: autenticar, exigirRol...La confusión aquí es doble, y por eso el error dura tardes enteras: el mensaje habla de CORS, pero la causa es el orden de los middlewares; y la petición que falla (OPTIONS) no es la que escribiste (POST). La regla de diagnóstico: cuando falle CORS, abre la pestaña de red del navegador y busca la petición OPTIONS. Si no está, el problema es otro. Si está y no devuelve 2xx, ahí está el fallo, y su código de estado te dice exactamente qué middleware lo interceptó.
- Cookies entre orígenes:
SameSite, Secure, HttpOnly
SameSite, Secure, HttpOnlySi Tienda Aroma usara cookies de sesión en lugar de Authorization: Bearer, la configuración necesaria sería:
| Atributo | Efecto | Por qué |
|---|---|---|
HttpOnly |
JavaScript no puede leerla | Un XSS no roba la sesión |
Secure |
Solo se envía por HTTPS | No viaja en claro |
SameSite=None |
Se envía entre sitios | Necesario si la API está en otro dominio |
SameSite=Lax |
Solo en navegación de nivel superior | Por defecto en navegadores modernos |
SameSite=Strict |
Nunca entre sitios | Máxima protección CSRF |
Path=/ |
Ámbito | — |
Max-Age |
Caducidad | Sesión corta |
El problema salta a la vista: SameSite=None es exactamente lo que hay que poner para que funcione entre tiendaaroma.example y api.tiendaaroma.example, y es exactamente lo que reabre la puerta al CSRF. A cambio hay que añadir tokens anti-CSRF, y encima SameSite=None requiere Secure y está sujeto a las restricciones de cookies de terceros que los navegadores llevan años endureciendo.
Por eso Tienda Aroma usa Authorization: Bearer:
| Cookie de sesión | Authorization: Bearer |
|
|---|---|---|
| Envío automático | Sí, el navegador la adjunta | No, el código la pone |
| Vulnerable a CSRF | Sí | No |
Necesita SameSite=None entre dominios |
Sí | — |
| Afectada por el bloqueo de cookies de terceros | Sí | No |
| Funciona igual en móvil nativo | No | Sí |
| Vulnerable a XSS | Menos con HttpOnly |
Sí si se guarda en localStorage |
Requiere credentials: true en CORS |
Sí | No |
Ninguna opción es perfecta: Bearer elimina el CSRF pero traslada el riesgo al almacenamiento del token en el cliente (04-03). Para una API consumida por SPA, app móvil y terceros, Bearer es la opción coherente, y por eso credentials: false en nuestra configuración de CORS.
- CSRF: qué es y por qué Bearer es inmune
CSRF (Cross-Site Request Forgery) es un ataque que aprovecha que el navegador adjunta las cookies automáticamente. La víctima, con sesión abierta en Tienda Aroma, visita una página maliciosa:
<!-- En https://sitio-malicioso.example -->
<form action="https://api.tiendaaroma.example/v1/pedidos" method="POST"
enctype="text/plain" id="f">
<input name='{"clienteId":"cli_842","lineas":[{"cafeId":"caf_999","cantidad":50}],"x":"' value='"}'>
</form>
<script>document.getElementById('f').submit();</script>El navegador envía la petición con las cookies de sesión de la víctima. El atacante no puede leer la respuesta (la SOP se lo impide), pero no le hace falta: el pedido ya se ha creado. CSRF es un ataque de escritura a ciegas.
Por qué Tienda Aroma es inmune:
- Usa
Authorization: Bearer, y el navegador no adjunta esa cabecera automáticamente. Un formulario de otro sitio no puede añadirla. - Exige
Content-Type: application/json, que un formulario HTML no puede producir (el truco delenctype="text/plain"del ejemplo es precisamente para intentar esquivarlo, y nuestroexpress.json({type: [...]})rechaza ese tipo). - Toda escritura dispara preflight, y el preflight fallaría porque
sitio-malicioso.exampleno está en la lista blanca.
Las tres son consecuencia de decisiones que ya estaban tomadas. Si algún día se migrara a cookies, harían falta defensas explícitas:
| Defensa | Cómo funciona | Nota |
|---|---|---|
SameSite=Lax o Strict |
El navegador no envía la cookie entre sitios | La primera línea, y a menudo suficiente |
| Token anti-CSRF sincronizador | El servidor emite un token que el cliente devuelve en una cabecera | Estándar; el atacante no puede leerlo |
| Doble envío de cookie | Cookie + misma cabecera; el servidor compara | Más simple, algo menos robusto |
Comprobar Origin |
Rechazar si el Origin no es esperado |
Buen refuerzo, no defensa única |
Y la advertencia final del apartado: CSRF y CORS son cosas distintas y a menudo se confunden. CORS controla quién puede leer respuestas; CSRF explota que las peticiones se envían con credenciales. Configurar CORS estrictamente no elimina el CSRF si usas cookies, porque las peticiones simples se envían igualmente.
- Otras políticas del navegador
CORS no es la única política que gobierna la relación entre la SPA y la API.
Referrer-Policy, que ya activamos con helmet en 04-02 con el valor no-referrer. Controla cuánta información de la URL de origen se envía al navegar o al cargar recursos. Importa porque nuestras URIs contienen identificadores (/v1/pedidos/ped_5001) y no queremos que aparezcan en los logs de terceros.
Permissions-Policy (antes Feature-Policy) declara qué capacidades del navegador puede usar un documento: cámara, micrófono, geolocalización. Es una cabecera para documentos HTML, así que la pone la SPA, no la API:
Content-Security-Policy en la SPA, y en concreto la directiva connect-src, que es el complemento simétrico de CORS: mientras CORS dice qué orígenes pueden leernos, connect-src dice a qué orígenes puede llamar la SPA.
Content-Security-Policy:
default-src 'self';
connect-src 'self' https://api.tiendaaroma.example;
img-src 'self' https://imagenes.tiendaaroma.example data:;
script-src 'self';
style-src 'self';
frame-ancestors 'none';
base-uri 'self'Su valor defensivo es concreto: si un atacante logra inyectar JavaScript en la SPA (un XSS), connect-src le impide exfiltrar los datos a su propio servidor, porque el navegador bloqueará el fetch a un dominio no declarado. Es la razón de que CSP sea una defensa en profundidad valiosa incluso cuando ya sanitizas las entradas.
La API, que no sirve HTML, mantiene su default-src 'none' de 04-02: nada que cargar, nada que ejecutar.
- CORS no autoriza nada
Merece un apartado propio porque es el malentendido con peores consecuencias.
CORS es una instrucción para el navegador. No es un control de acceso.
| Lo que CORS sí hace | Lo que CORS no hace |
|---|---|
| Decir al navegador qué origen puede leer la respuesta | Impedir que alguien llame a tu API |
| Proteger al usuario de que otra web use su sesión | Autenticar a nadie |
| Permitir leer cabeceras concretas | Autorizar operaciones |
| Reducir la superficie desde el navegador | Proteger de curl, scripts o bots |
Una API con Access-Control-Allow-Origin: https://tiendaaroma.example y sin autenticación es una API completamente abierta: cualquiera con curl accede a todo. Y una API con la política de CORS más restrictiva del mundo sigue siendo vulnerable a BOLA si no comprueba la propiedad de los recursos (04-02).
La regla de oro:
Configura CORS pensando en proteger a tus usuarios en su navegador. Configura autenticación y autorización pensando en proteger tus datos de todo lo demás. Nunca sustituyas la segunda por la primera.
- Probar CORS con
curl y con las DevTools
curl y con las DevToolsCon curl
curl no aplica CORS, pero sirve perfectamente para inspeccionar las cabeceras que emite el servidor, que es lo que queremos verificar.
# 1. Origen permitido: debe aparecer Access-Control-Allow-Origin con ese valor.
curl -sI https://api.tiendaaroma.example/v1/cafes \
-H 'Origin: https://tiendaaroma.example' | grep -i 'access-control\|vary'
# Esperado:
# access-control-allow-origin: https://tiendaaroma.example
# access-control-expose-headers: Location, Link, ETag, ...
# vary: Origin
# 2. Origen NO permitido: NO debe aparecer la cabecera. Ojo: el cuerpo llega igual,
# porque curl no es un navegador. Lo que comprobamos es la ausencia de la cabecera.
curl -sI https://api.tiendaaroma.example/v1/cafes \
-H 'Origin: https://sitio-malicioso.example' | grep -i 'access-control-allow-origin'
# Esperado: sin salida
# 3. Simular un preflight completo.
curl -si -X OPTIONS https://api.tiendaaroma.example/v1/pedidos \
-H 'Origin: https://tiendaaroma.example' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: authorization,content-type,idempotency-key'
# Esperado: HTTP/1.1 204 con Allow-Methods, Allow-Headers y Max-Age.
# 4. Comprobar que el preflight NO necesita autenticación (el fallo del apartado 12).
curl -so /dev/null -w '%{http_code}\n' -X OPTIONS \
https://api.tiendaaroma.example/v1/pedidos \
-H 'Origin: https://tiendaaroma.example' \
-H 'Access-Control-Request-Method: POST'
# Esperado: 204. Si sale 401, el middleware de autenticación está mal colocado.Con las DevTools
- Pestaña Red, filtro «Fetch/XHR», y activa «Preserve log» para que no se pierdan al navegar.
- Busca la petición
OPTIONS. Si falla, ahí está el problema; su código de estado señala el culpable. - En «Headers», compara
Access-Control-Request-Headers(lo que pide el navegador) conAccess-Control-Allow-Headers(lo que concede el servidor). La diferencia es tu error. - Comprueba en la respuesta real que están
Access-Control-Allow-OriginyAccess-Control-Expose-Headers. - Si la respuesta llega pero una cabecera es
nullen el código, esExpose-Headers. Las DevTools sí muestran todas las cabeceras aunque JavaScript no pueda leerlas: esa discrepancia entre «la veo en las DevTools peroheaders.get()danull» es la firma inconfundible del problema.
Una prueba automatizada
// pruebas/integracion/cors.prueba.js
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { app } from '../../src/app.js';
describe('CORS', () => {
it('refleja el origen permitido y añade Vary', async () => {
const r = await request(app)
.get('/v1/cafes')
.set('Origin', 'https://tiendaaroma.example');
assert.equal(r.headers['access-control-allow-origin'], 'https://tiendaaroma.example');
assert.match(r.headers['vary'] ?? '', /Origin/);
});
it('no emite Allow-Origin para un origen desconocido', async () => {
const r = await request(app)
.get('/v1/cafes')
.set('Origin', 'https://sitio-malicioso.example');
assert.equal(r.headers['access-control-allow-origin'], undefined);
});
it('responde el preflight SIN exigir autenticación', async () => {
const r = await request(app)
.options('/v1/pedidos')
.set('Origin', 'https://tiendaaroma.example')
.set('Access-Control-Request-Method', 'POST')
.set('Access-Control-Request-Headers', 'authorization,content-type,idempotency-key');
assert.equal(r.status, 204); // NUNCA 401
assert.match(r.headers['access-control-allow-headers'], /Idempotency-Key/i);
assert.match(r.headers['access-control-allow-methods'], /POST/);
});
it('expone las cabeceras que la SPA necesita leer', async () => {
const r = await request(app)
.get('/v1/cafes')
.set('Origin', 'https://tiendaaroma.example');
const expuestas = (r.headers['access-control-expose-headers'] ?? '').toLowerCase();
for (const cabecera of ['link', 'etag', 'retry-after', 'aroma-ratelimit-restantes']) {
assert.ok(expuestas.includes(cabecera), `falta exponer ${cabecera}`);
}
});
});La tercera prueba es la más valiosa de las cuatro: fija el orden de los middlewares. Si alguien mueve cors después de la autenticación, esta prueba falla con un 401 y el problema se detecta en la integración continua en vez de en la consola de un desarrollador de la SPA.
Errores Comunes y Consejos
Usar app.use(cors()) sin opciones. Equivale a Access-Control-Allow-Origin: * en toda la API. Funciona, y por eso es tan peligroso: el problema no se manifiesta nunca.
Validar el origen con startsWith o una expresión regular laxa. https://tiendaaroma.example.evil.example pasaría el filtro. Igualdad exacta contra un Set.
Olvidar Vary: Origin. Produce fallos intermitentes que dependen de qué haya cacheado la CDN, y son de los bugs más difíciles de reproducir.
Poner cors después de la autenticación. El preflight recibe 401 y nada funciona, con un mensaje que apunta en otra dirección.
Olvidar exposedHeaders. La SPA no puede leer Location, Link, ETag ni Retry-After, y buena parte del trabajo de los módulos 2, 3 y 4 queda invisible para ella.
Intentar arreglar CORS desde el cliente. No se puede: la decisión es del servidor. Los proxies y extensiones que «desactivan CORS» solo sirven en tu máquina y ocultan el problema real.
Creer que mode: 'no-cors' en el fetch soluciona algo. Devuelve una respuesta opaca: no puedes leer ni el cuerpo ni el estado. Casi nunca es lo que quieres.
Añadir un origen a la lista blanca «temporalmente» para depurar. Los * temporales se quedan para siempre. Usa un entorno de desarrollo con su propia configuración.
Consejo: la respuesta al preflight también necesita las cabeceras de seguridad. Por eso helmet va antes que CORS.
Consejo: cuando falle CORS, mira la pestaña de red antes que la consola. El mensaje de la consola es un resumen; la petición OPTIONS es la evidencia.
Consejo: documenta la lista de orígenes permitidos. Cuando la SPA cambie de dominio, alguien tendrá que actualizarla, y si no está escrito nadie sabrá dónde.
Ejercicios
Ejercicio 1: preflight o no
Para cada petición desde https://tiendaaroma.example, di si dispara preflight y por qué:
fetch('https://api.tiendaaroma.example/v1/cafes')fetch('https://api.tiendaaroma.example/v1/cafes', { headers: { Authorization: 'Bearer x' } })fetch('https://api.tiendaaroma.example/v1/pedidos', { method: 'POST', body: 'hola', headers: { 'Content-Type': 'text/plain' } })fetch('https://api.tiendaaroma.example/v1/pedidos', { method: 'POST', body: '{}', headers: { 'Content-Type': 'application/json' } })fetch('https://tiendaaroma.example/api/cafes')fetch('https://api.tiendaaroma.example/v1/cafes/caf_001', { method: 'DELETE' })
Ejercicio 2: diagnosticar cuatro fallos
Diagnostica cada situación y propón la corrección concreta:
- (a) La SPA crea un pedido correctamente (
201), perorespuesta.headers.get('Location')devuelvenull. - (b) El panel interno funciona desde la máquina de un desarrollador y falla en producción con
No 'Access-Control-Allow-Origin' header. - (c) Tras poner una CDN delante, la SPA falla una de cada cinco veces con un
Allow-Originque corresponde al panel. - (d)
POST /v1/pedidosfalla desde la SPA con «Response to preflight request doesn't pass access control check», pero el mismoPOSTconcurlfunciona.
Ejercicio 3: configuración para un tercero
Tienda Aroma va a permitir que CataBox (https://catabox.example) llame a la API desde su propia SPA en el navegador, usando OAuth con Authorization: Bearer y solo el ámbito pedidos.leer. Escribe la configuración de CORS necesaria y responde: ¿basta con añadir el origen a la lista blanca para que CataBox pueda leer los pedidos? ¿Qué más hace falta y qué no aporta CORS aquí?
Soluciones
Solución 1
| Nº | ¿Preflight? | Por qué |
|---|---|---|
| 1 | No | GET sin cabeceras especiales: es una petición simple |
| 2 | Sí | Authorization no está en la lista de cabeceras seguras |
| 3 | No | POST con text/plain cumple las condiciones de petición simple |
| 4 | Sí | Content-Type: application/json no está permitido en simples |
| 5 | No aplica | Es el mismo origen: CORS no interviene en absoluto |
| 6 | Sí | DELETE no está entre los métodos de las peticiones simples |
Observación sobre el caso 3: aunque no dispare preflight, nuestra API lo rechazaría igualmente con un 400, porque express.json está configurado con type: ['application/json', 'application/merge-patch+json'] y no parsea text/plain. Ese rechazo es justamente lo que hace que el truco del formulario CSRF del apartado 14 no funcione.
Solución 2
(a) Falta Location en exposedHeaders. La respuesta llega completa y la cabecera está ahí —se ve en las DevTools—, pero el navegador no deja que JavaScript la lea porque no es una de las siete seguras de lista. Corrección: añadir Location a exposedHeaders en src/config/cors.js.
(b) La variable ORIGENES_PERMITIDOS de producción no incluye el dominio del panel. En desarrollo funcionaba porque origenesPermitidos() añade localhost cuando NODE_ENV es desarrollo. Corrección: añadir https://panel.tiendaaroma.example a la configuración del entorno de producción y redesplegar. Verificación: curl -sI -H 'Origin: https://panel.tiendaaroma.example' ....
(c) Falta Vary: Origin. La CDN cachea la respuesta —incluida su cabecera Access-Control-Allow-Origin— sin saber que depende de Origin, y la sirve indistintamente a los dos orígenes. El fallo es intermitente porque depende de qué respuesta esté cacheada. Corrección: emitir Vary: Origin (el paquete cors lo hace cuando se usa una función origin, pero hay que verificar que la CDN lo respeta y no lo elimina).
(d) El preflight no llega a responderse correctamente. curl funciona porque no hace preflight. Las dos causas probables, en orden: el middleware de autenticación se ejecuta antes que cors y responde 401 al OPTIONS; o falta Idempotency-Key en allowedHeaders, con lo que el navegador rechaza el preflight aunque devuelva 204. Diagnóstico: mirar la petición OPTIONS en la pestaña de red. Si devuelve 401, es lo primero; si devuelve 204 pero el Access-Control-Allow-Headers no incluye idempotency-key, es lo segundo.
Solución 3
// src/config/cors.js (fragmento)
// En .env de producción:
// ORIGENES_PERMITIDOS=https://tiendaaroma.example,https://panel.tiendaaroma.example,https://catabox.exampleNo hace falta nada más en la configuración: CataBox usa Authorization: Bearer, que ya está en allowedHeaders, y credentials sigue en false porque no hay cookies.
¿Basta con eso para que CataBox lea los pedidos? No. Añadir el origen solo permite que el navegador entregue la respuesta al JavaScript de CataBox. Para que la API devuelva datos hacen falta tres cosas más, todas del lado del servidor:
- Un token válido emitido por el servidor de autorización, con
audigual a nuestra API (04-03). - El ámbito
pedidos.leeren ese token, comprobado porexigirAmbito. - La comprobación de propiedad: el token solo da acceso a los pedidos de su
sub, no a los de cualquiera (04-02).
Qué no aporta CORS aquí: absolutamente ninguna autorización. Si mañana el backend de CataBox llama a la API desde su servidor, no habrá Origin ni CORS de por medio, y el acceso seguirá funcionando o fallando exactamente igual, según el token. Y una nota práctica: como la SPA de CataBox es un cliente público, debe usar Authorization Code + PKCE y no puede guardar un client_secret (04-03).
Conclusión
CORS deja de ser un misterio en cuanto se entiende que no es una defensa, sino una relajación controlada de una defensa que ya existe: la política del mismo origen, que impide que el JavaScript de una página lea respuestas de otro origen. Sabes qué es exactamente un origen —esquema, host y puerto, con el subdominio contando como distinto—, que la petición se envía aunque la respuesta se bloquee, y por qué curl, Aroma Móvil y el backend de CataBox no ven nada de esto. Distingues peticiones simples de las que disparan preflight, y has visto el intercambio OPTIONS completo en crudo, con la observación clave de que la respuesta real también necesita sus cabeceras de CORS. Conoces las siete cabeceras del protocolo, la importancia de Vary: Origin frente a las cachés, y sobre todo Access-Control-Expose-Headers, que era el eslabón que faltaba para que la SPA pueda leer Location, Link, ETag, Retry-After y las Aroma-RateLimit-* que llevamos tres lecciones construyendo. En el proyecto tienes src/config/cors.js con lista blanca por entorno validada al arrancar, y el middleware en la posición 4 de src/app.js: después de helmet, antes del rate limiting, del parser y de toda autenticación —que es precisamente lo que evita el clásico preflight con 401—. Y tienes claro por qué Tienda Aroma usa Authorization: Bearer en lugar de cookies, lo que la hace inmune a CSRF sin necesidad de tokens sincronizadores.
Con la API asegurada, delimitada y accesible desde los orígenes correctos, queda hacerla rápida. En 04-06, Caché HTTP y rendimiento, recuperaremos la restricción «cacheable» de 01-04 y la convertiremos en implementación: los niveles de caché desde el navegador hasta la base de datos; Cache-Control a fondo, con max-age, s-maxage, private, ese no-cache que no significa «no cachear», stale-while-revalidate e immutable; la validación condicional con ETag y Last-Modified que produce un 304 sin cuerpo; y el reencuentro que llevamos anunciando desde 03-05, cuando If-Match y el 412 cierren el círculo de la concurrencia optimista y expliquen por qué una cabecera es mejor sitio que el cuerpo para el campo version. Añadiremos src/middleware/cache.js, veremos la invalidación —el problema difícil—, la caché de servidor con Redis y su patrón cache-aside, la compresión, el N+1, el 202 para las operaciones largas, y por qué hay que medir p50, p95 y p99 antes de optimizar nada.
Curso de REST API: Principios de Diseño y Desarrollo de APIs RESTful
Módulo 1: Introducción a las APIs RESTful
- ¿Qué es una API?
- Historia y evolución de las APIs
- Fundamentos de HTTP para APIs
- Principios básicos de REST
- Modelo de madurez de Richardson y HATEOAS
- REST vs. SOAP
- REST frente a GraphQL, gRPC y webhooks
Módulo 2: Diseño de APIs RESTful
- Principios de diseño de APIs RESTful
- Recursos y URIs
- Métodos HTTP
- Códigos de estado HTTP
- Representaciones, cabeceras y negociación de contenido
- Filtrado, ordenación, paginación y búsqueda
- Versionado de APIs
- Documentación de APIs
Módulo 3: Desarrollo de APIs RESTful
- Configuración del entorno de desarrollo
- Creación de un servidor básico
- Manejo de peticiones y respuestas
- Validación de datos de entrada
- Persistencia y capa de acceso a datos
- Autenticación y autorización
- Manejo de errores
- Pruebas y validación
Módulo 4: Buenas Prácticas y Seguridad
- Buenas prácticas en el diseño de APIs
- Seguridad en APIs RESTful
- OAuth 2.0 y OpenID Connect en la práctica
- Rate limiting y throttling
- CORS y políticas de seguridad
- Caché HTTP y rendimiento
- Observabilidad: logs, métricas y trazas
Módulo 5: Herramientas y Frameworks
- Postman para pruebas de APIs
- Swagger y OpenAPI para documentación
- Frameworks populares para APIs RESTful
- Contratos, mocks y pruebas automatizadas de API
- Integración continua y despliegue
- API gateways y portales de desarrollador
