¿Contra qué protege realmente CORS, y cuál es la diferencia real entre solicitudes simples y con preflight?
CORS parece una regla de firewall, pero es un contrato que el navegador hace cumplir entre orígenes — saber qué bloquea, y qué nunca toca, evita configuraciones costosas.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Contra qué protege realmente CORS, y cuál es la diferencia real entre solicitudes simples y con preflight?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Por qué los entrevistadores hacen esta pregunta
"¿Qué es CORS?" filtra a quienes solo han copiado y pegado Access-Control-Allow-Origin: * hasta que el error rojo de la consola desaparece. La pregunta de seguimiento que realmente reciben los ingenieros senior es más afilada: ¿contra qué protege CORS exactamente, y por qué algunas llamadas cross-origin se disparan de inmediato mientras que otras primero disparan un viaje de ida y vuelta extra? Equivocarse en esto en producción suele derivar en uno de dos modos de falla: o un cliente legítimo queda bloqueado porque nadie entendió qué solicitudes necesitan un manejador de preflight, o un "arreglo" desactiva silenciosamente la protección del navegador para un endpoint que confía en cookies.
#La regla que CORS relaja, no la que crea
El comportamiento por defecto del navegador es la política del mismo origen (SOP, same-origin policy): un script que corre en https://evil.example no puede leer la respuesta de una solicitud que hace a https://bank.example, aunque nada impide que el navegador envíe esa solicitud en primer lugar, con las cookies de bank.example incluidas, exactamente como si el usuario hubiera escrito la URL él mismo. Este es el detalle que la mayoría entiende al revés: la SOP no impide que la solicitud se dispare, impide que el script de la página que llama lea lo que vuelve. CORS es el protocolo basado en encabezados que un servidor usa para decir "las respuestas hacia este otro origen específico son seguras de exponer a su JavaScript" — es una relajación opcional de la SOP, no una restricción nueva encima de ella.
#Solicitudes simples: se envían primero, se juzgan después
Una solicitud cross-origin es "simple" — se envía directamente, sin viaje de ida y vuelta previo — solo cuando se cumple todo esto:
- El método es
GET,HEADoPOST. - Los únicos encabezados presentes son los que el navegador pone automáticamente, más una lista corta permitida (
Accept,Accept-Language,Content-Language, yContent-Typelimitado aapplication/x-www-form-urlencoded,multipart/form-dataotext/plain). - El cuerpo no es un
ReadableStream.
Para una solicitud simple, el navegador adjunta un encabezado Origin, la envía de inmediato, y luego comprueba si el Access-Control-Allow-Origin de la respuesta coincide con ese origen (o es *, cuando no hay credenciales de por medio). Si no coincide, el navegador descarta la respuesta antes de que tu JavaScript la toque — pero para entonces la solicitud ya se ejecutó por completo del lado del servidor.
#Solicitudes con preflight: pedir permiso antes de actuar
Todo lo que quede fuera de esa lista corta lleva preflight: PUT, PATCH, DELETE; un encabezado personalizado como Authorization o X-Request-Id; o —el que atrapa a casi todo el mundo al menos una vez— Content-Type: application/json, que no está en la lista permitida de simples. Antes de enviar la solicitud real, el navegador emite automáticamente una solicitud OPTIONS que describe lo que está por hacer, mediante Access-Control-Request-Method y Access-Control-Request-Headers. El servidor debe responder con los encabezados Access-Control-Allow-* correspondientes, o el navegador jamás enviará la solicitud real. Este es el único caso en que CORS realmente bloquea la llamada en sí, no solo la lectura de su respuesta — por eso casi toda API JSON en producción necesita un manejador de OPTIONS correctamente configurado en algún punto de su stack de middleware.
#Simple vs. con preflight, lado a lado
| Simple | Con preflight | |
|---|---|---|
| Métodos | GET, HEAD, POST | PUT, PATCH, DELETE, o cualquier método con encabezados personalizados |
| Content-Type | form-urlencoded, multipart/form-data, text/plain | application/json, application/xml, cualquier otro |
| Encabezados personalizados (Authorization, X-*) | No permitidos | Disparan preflight |
| Viajes de ida y vuelta | 1 | 2 (OPTIONS, y luego la solicitud real) |
| ¿Puede el servidor bloquear la llamada real? | Nunca — solo se bloquea la lectura de la respuesta | Sí — un OPTIONS fallido detiene la solicitud real |
#El intercambio en la red
# 1) El navegador envía esto automáticamente — nunca llamas OPTIONS tú mismoOPTIONS /orders HTTP/1.1Host: api.shop.exampleOrigin: https://shop-frontend.exampleAccess-Control-Request-Method: POSTAccess-Control-Request-Headers: authorization, content-type# 2) El servidor debe responder antes de que el navegador envíe algo realHTTP/1.1 204 No ContentAccess-Control-Allow-Origin: https://shop-frontend.exampleAccess-Control-Allow-Methods: GET, POST, PUT, DELETEAccess-Control-Allow-Headers: authorization, content-typeAccess-Control-Allow-Credentials: trueAccess-Control-Max-Age: 600# 3) Solo ahora el navegador envía la solicitud realPOST /orders HTTP/1.1Host: api.shop.exampleOrigin: https://shop-frontend.exampleAuthorization: Bearer eyJhbGciOiJIUzI1NiJ9...Content-Type: application/json{"sku":"ABC123","qty":2}HTTP/1.1 201 CreatedAccess-Control-Allow-Origin: https://shop-frontend.exampleAccess-Control-Allow-Credentials: trueContent-Type: application/json{"orderId":"o_9f8e7d","status":"pending"}
#Código del cliente y del servidor
// Cliente — este fetch lleva preflight por dos razones independientes:// un header Authorization personalizado, y un Content-Type JSON.async function createOrder(order, token) {const res = await fetch("https://api.shop.example/orders", {method: "POST",credentials: "include", // enviar cookies también cross-originheaders: {"Content-Type": "application/json","Authorization": `Bearer ${token}`,},body: JSON.stringify(order),});if (!res.ok) throw new Error(`Order failed: ${res.status}`);return res.json();}
// Servidor (Express) — lista blanca de orígenes, nunca un "*" reflejado a ciegasconst ALLOWED_ORIGINS = new Set(["https://shop-frontend.example"]);app.use((req, res, next) => {const origin = req.headers.origin;if (origin && ALLOWED_ORIGINS.has(origin)) {res.setHeader("Access-Control-Allow-Origin", origin);res.setHeader("Access-Control-Allow-Credentials", "true");res.setHeader("Vary", "Origin"); // que un CDN no cachee esto para el origen equivocado}if (req.method === "OPTIONS") {res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");res.setHeader("Access-Control-Allow-Headers", "authorization, content-type");res.setHeader("Access-Control-Max-Age", "600");return res.sendStatus(204);}next();});
🧠 Check your understanding
0/3 · 0/3 answered1. Tu frontend en https://app.example hace un GET cross-origin con solo encabezados por defecto hacia https://api.example/status, y el servidor nunca configura Access-Control-Allow-Origin. ¿Qué sucede realmente?
2. ¿Cuál de estas fuerza una solicitud OPTIONS de preflight?
3. Necesitas que las solicitudes cross-origin incluyan cookies (credentials: 'include' en fetch). ¿Qué debe evitar el servidor?