El error blocked by CORS policy es probablemente el mensaje más malinterpretado del desarrollo web. La intuición dice que "CORS me bloquea", pero es exactamente al revés: CORS es el mecanismo que permite hacer lo que el navegador, por seguridad, tiene prohibido por defecto. Entender esta inversión lo cambia todo — y de repente las cabeceras mágicas dejan de ser copy-paste de Stack Overflow.
El origen del problema: Same-Origin Policy
Los navegadores aplican la Same-Origin Policy (SOP): un script cargado desde https://app.com no puede leer respuestas de otro origen — distinto dominio, puerto o protocolo:
https://app.com/page1 → https://app.com/api ✓ mismo origen
https://app.com → https://api.app.com ✗ dominio distinto
https://app.com → https://app.com:8080 ✗ puerto distinto
http://app.com → https://app.com ✗ protocolo distinto
¿Por qué existe esta restricción? Porque estás logueado en tu banco en una pestaña. Si cualquier web aleatoria pudiera lanzar peticiones a banco.com/transferencias y leer la respuesta, tu cookie de sesión viaja sola y el script lee el saldo. SOP convierte ese escenario en imposible: la petición puede salir, pero el JavaScript no puede leer la respuesta de otro origen sin permiso explícito.
Matiz crucial: SOP es política del navegador. curl, Postman o tu backend pueden llamar a cualquier API libremente. Por eso "en Postman funciona y en mi app no": no es la API, es que Postman no implementa SOP.
Qué es realmente CORS
CORS (Cross-Origin Resource Sharing) es el sistema estándar para relajar SOP de forma controlada: el servidor declara mediante cabeceras HTTP qué orígenes externos tienen permiso para leer sus recursos. El navegador actúa como policía: comprueba esas cabeceras y decide si entregar la respuesta al JavaScript.
La cabecera fundamental vive en la respuesta:
Access-Control-Allow-Origin: https://app.com
o, para APIs públicas, cualquiera:
Access-Control-Allow-Origin: *
Si tu petición a la API carece de esta cabecera (o el origen no coincide), el navegador recibe los datos pero los oculta a tu JavaScript y muestra el error de consola. Los datos llegaron; el permiso no.
Peticiones simples y preflight
No toda petición cross-origin necesita permiso previo. El estándar define las "simple requests": GET, HEAD, POST con content types clásicos (application/x-www-form-urlencoded, multipart/form-data, text/plain) y cabeceras seguras. Estas se envían directamente y el navegador valida las cabeceras CORS al recibir la respuesta.
Todo lo demás dispara un preflight: antes de la petición real, el navegador envía una petición OPTIONS preguntando qué se permite:
OPTIONS /api/datos HTTP/1.1
Origin: https://app.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization, Content-Type
El servidor debe responder declarando qué acepta:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Solo si todas las respuestas son satisfactorias, el navegador lanza la petición real. Tres consecuencias prácticas:
- Tu endpoint debe responder a
OPTIONS— muchos frameworks lo manejan con middleware CORS, pero un handler manual olvida este caso y todo falla misteriosamente solo para métodos no-GET. - El preflight duplica latencia en peticiones nuevas.
Access-Control-Max-Agecachea la respuesta y evita repetirlo. - Si el servidor devuelve error en el OPTIONS (404, 405, auth requerida), nunca llega la petición real. Síntoma típico: "la API exige token pero me da 401 incluso con token correcto" — porque el fallo está en el preflight, que no lleva token.
Cabeceras completas: la tabla de referencia
| Cabecera | Dónde | Función |
|---|---|---|
Access-Control-Allow-Origin |
Respuesta | Orígenes permitidos (* o uno concreto) |
Access-Control-Allow-Methods |
Preflight | Métodos HTTP permitidos |
Access-Control-Allow-Headers |
Preflight | Cabeceras personalizadas permitidas |
Access-Control-Max-Age |
Preflight | Segundos de caché del preflight |
Access-Control-Allow-Credentials |
Ambas | Permite cookies/credenciales |
Access-Control-Expose-Headers |
Respuesta | Cabeceras de respuesta legibles desde JS |
Origin |
Petición | Enviada automáticamente por el navegador |
Access-Control-Request-Method |
Preflight | Método que quiere usar la petición real |
Dos detalles que sorprenden:
Expose-Headers: aunque la respuesta llegue completa, JavaScript solo ve por defecto las cabeceras básicas (Cache-Control, Content-Language...). Si tu API devuelve X-Request-Id y quieres leerlo, el servidor debe listarla aquí.
Allow-Credentials: para enviar cookies entre orígenes se necesitan tres piezas simultáneas — credentials: "include" en el fetch del cliente, Access-Control-Allow-Credentials: true en el servidor, y un origen concreto (el comodín * es inválido con credenciales). Falte una y las cookies no viajan.
Errores frecuentes y su diagnóstico
"Funciona con curl pero no en el navegador": no es bug, es SOP. El servidor necesita emitir las cabeceras CORS.
"Puse Allow-Origin: * y sigue fallando": ¿la petición lleva credenciales? Entonces * es ilegal; especifica el origen exacto y añade Allow-Credentials. ¿O quizá el error es de preflight y el middleware no cubre OPTIONS?
"Añadí las cabeceras pero el navegador no las ve": revisa si hay doble emisión (middleware + respuesta manual duplican la cabecera y el navegador rechaza valores duplicados) o si un proxy/CDN las está eliminando. Inspecciona con DevTools pestaña Network la respuesta REAL.
"Redirect rompe CORS": si https://api.com/a redirige a otro origen, cada salto necesita sus propias cabeceras CORS. Un redirect hacia login sin CORS produce el error genérico.
Cómo configurarlo bien (y seguro)
En Express con su middleware estándar:
const cors = require("cors");
app.use(cors({
origin: ["https://app.com", "https://staging.app.com"],
methods: ["GET", "POST", "PUT", "DELETE"],
credentials: true,
maxAge: 86400
}));
Reglas de oro:
- Nunca reflejes ciegamente el header
Originde la petición conAllow-Credentials: true— eso equivale a permitir cualquier web autenticada contra tu API, que es justo lo que SOP impedía. Lista blanca explícita siempre. *solo para datos genuinamente públicos y sin cookies.- Restringe
Allow-Headersa lo que realmente usas: cada cabecera extra es superficie de ataque. - Recuerda que CORS protege a tus usuarios, no a tu API: un atacante con curl ignora CORS por completo. No es sustituto de autenticación ni rate limiting.
Inspecciona tus propias cabeceras
Para auditar qué emite tu servidor —CORS incluido— nuestro analizador de cabeceras HTTP te muestra la respuesta completa de cualquier URL, y si quieres profundizar en el resto de cabeceras de seguridad que deberías emitir junto a estas, tienes la guía de cabeceras de seguridad.
Preguntas frecuentes
¿CORS aplica a imágenes y fuentes? A la lectura desde JavaScript, sí. Una etiqueta <img src> puede cargar recursos cross-origin sin CORS (por eso el canvas requiere crossorigin="anonymous" para poder leer sus píxeles sin contaminarse).
¿Puedo desactivar CORS para desarrollar? Extensiones que inyectan las cabeceras existen, pero ensucian el diagnóstico. Mejor: proxy de desarrollo (Vite, Next rewrites) que convierte tus peticiones en same-origin.
¿CORS protege contra CSRF? No. CSRF aprovecha peticiones que no necesitan leer la respuesta. Protégete con tokens anti-CSRF y cookies SameSite.
Analiza las cabeceras de tu API con el comprobador de cabeceras HTTP, gratis y sin registro.