CORS a fondo: configurar Cross-Origin Resource Sharing
Entiende cómo funciona CORS realmente a nivel de protocolo e implementa configuraciones seguras y correctas sin recurrir a comodines que lo permiten todo.

Los errores de CORS son la pesadilla del desarrollo frontend. El instinto cuando ves "Access-Control-Allow-Origin" en rojo es poner Access-Control-Allow-Origin: * en el servidor y seguir adelante. Esto funciona en desarrollo y crea agujeros de seguridad en producción.
Entender CORS a nivel de protocolo—qué envía el navegador, qué debería responder el servidor y por qué existe el preflight—convierte una experiencia frustrante de depuración en una configuración sencilla.
Qué ocurre antes de que tu código se ejecute
CORS lo impone el navegador, no el servidor. El servidor se limita a declarar su política mediante cabeceras de respuesta. El navegador decide si bloquea la respuesta basándose en esas cabeceras.
// ❌ Common misconception: CORS blocks the request
// Reality: The request IS sent. The browser blocks the RESPONSE.
// Your server processes the request either way!
// This means CORS alone doesn't prevent server-side effects.
// A POST that creates a database record still executes.
// The browser just hides the response from JavaScript.// The browser's CORS decision flow:
interface CORSCheck {
step1: "Is request origin different from resource origin?";
step2: "If same-origin → allow, no CORS headers needed";
step3: "If cross-origin → check Access-Control-Allow-Origin header";
step4: "If header matches origin → allow JavaScript to read response";
step5: "If header missing or mismatched → block response from JS";
}
// For non-simple requests, add a preflight step BEFORE step 1:
interface PreflightCheck {
trigger: "Custom headers, non-GET/POST methods, or non-simple content types";
action: "Send OPTIONS request with Access-Control-Request-* headers";
serverResponds: "With Access-Control-Allow-* headers declaring policy";
browserDecides: "Whether to send the actual request based on the policy";
}La clave es que CORS es un protocolo de negociación entre navegador y servidor. Las solicitudes de servidor a servidor, curl y Postman no implican CORS en absoluto porque no hay ningún navegador que imponga la política.
Las solicitudes preflight desmitificadas
Las solicitudes "simples" (GET, POST con content types de formulario, cabeceras limitadas) se saltan el preflight. Todo lo demás dispara una solicitud OPTIONS que debe tener éxito antes de que se envíe la solicitud real.
// ❌ Not understanding why a preflight is triggered
// "My GET request shouldn't need a preflight!"
// It does if you added custom headers:
fetch("https://api.example.com/data", {
headers: {
Authorization: "Bearer token123", // Custom header → preflight
"X-Request-Id": "abc", // Custom header → preflight
},
});// Express middleware: properly handling preflight
import express, { Request, Response, NextFunction } from "express";
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://staging.example.com",
]);
function corsMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
const origin = req.headers.origin;
// Only set CORS headers for allowed origins
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin"); // Critical for caching
// Handle preflight
if (req.method === "OPTIONS") {
res.setHeader(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, PATCH"
);
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Request-Id"
);
res.setHeader(
"Access-Control-Max-Age",
"86400" // Cache preflight for 24 hours
);
res.status(204).end();
return;
}
}
next();
}
const app = express();
app.use(corsMiddleware);La cabecera Vary: Origin es crucial y se olvida con frecuencia. Sin ella, una CDN podría cachear una respuesta con las cabeceras CORS de un origen y servirla a un origen distinto, provocando errores de CORS.
Validación dinámica de orígenes
Las APIs en producción a menudo necesitan permitir múltiples orígenes, incluidos subdominios y URLs de despliegues de vista previa. La validación basada en expresiones regulares resuelve esto sin recurrir a un comodín.
// ❌ Reflecting any origin back (equivalent to wildcard)
function unsafeCors(req: Request, res: Response, next: NextFunction) {
res.setHeader(
"Access-Control-Allow-Origin",
req.headers.origin ?? "*" // Reflects attacker's origin!
);
next();
}// ✅ Validating origins against a pattern
function isAllowedOrigin(origin: string): boolean {
const allowedPatterns = [
/^https:\/\/app\.example\.com$/,
/^https:\/\/[a-z0-9-]+\.preview\.example\.com$/,
/^https:\/\/staging\.example\.com$/,
];
// In development, also allow localhost
if (process.env.NODE_ENV === "development") {
allowedPatterns.push(/^http:\/\/localhost:\d+$/);
}
return allowedPatterns.some(pattern => pattern.test(origin));
}
function secureCorsMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
const origin = req.headers.origin;
if (origin && isAllowedOrigin(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
if (req.method === "OPTIONS") {
res.setHeader(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE"
);
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization"
);
res.setHeader("Access-Control-Max-Age", "86400");
res.status(204).end();
return;
}
}
next();
}Los patrones regex son estrictos: coinciden con estructuras de dominio exactas, no con subcadenas. Un patrón permisivo como /example\.com/ coincidiría con evil-example.com—ancla siempre tus patrones con ^ y $.
Credenciales y cookies entre orígenes
Cuando tu solicitud cross-origin necesita enviar cookies o usar autenticación HTTP, CORS se vuelve más restrictivo. El comodín * está explícitamente prohibido con credenciales.
// ❌ This doesn't work: wildcard + credentials
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Credentials", "true");
// Browser error: Cannot use wildcard with credentials
// ❌ Reflecting origin without validation + credentials
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
// Security hole: any site can make authenticated requests// ✅ Explicit origin with credentials
function credentialedCorsMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
const origin = req.headers.origin;
if (origin && isAllowedOrigin(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Vary", "Origin");
// Expose specific response headers to JavaScript
res.setHeader(
"Access-Control-Expose-Headers",
"X-Total-Count, X-Request-Id"
);
if (req.method === "OPTIONS") {
res.setHeader(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE"
);
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization"
);
res.setHeader("Access-Control-Max-Age", "3600");
res.status(204).end();
return;
}
}
next();
}// Client-side: must explicitly opt into credentials
const response = await fetch("https://api.example.com/user", {
credentials: "include", // Send cookies cross-origin
headers: {
"Content-Type": "application/json",
},
});La cabecera Access-Control-Expose-Headers suele pasarse por alto. Por defecto, JavaScript solo puede leer seis cabeceras de respuesta "CORS-safelisted". Las cabeceras personalizadas como X-Total-Count para paginación son invisibles a menos que se expongan explícitamente.
Configuración de CORS en los frameworks más comunes
Cada framework maneja CORS de forma distinta. Aquí tienes configuraciones seguras para los más populares.
// Next.js API route
import type { NextApiRequest, NextApiResponse } from "next";
const allowedOrigins = ["https://app.example.com"];
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const origin = req.headers.origin;
if (origin && allowedOrigins.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
}
if (req.method === "OPTIONS") {
res.setHeader("Access-Control-Allow-Methods", "GET, POST");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
res.setHeader("Access-Control-Max-Age", "86400");
return res.status(204).end();
}
res.json({ data: "response" });
}# FastAPI with strict CORS
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://app.example.com",
"https://staging.example.com",
],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Content-Type", "Authorization"],
expose_headers=["X-Total-Count"],
max_age=86400,
)// Go with chi router
package main
import (
"net/http"
"github.com/go-chi/cors"
)
func main() {
r := chi.NewRouter()
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://app.example.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
ExposedHeaders: []string{"X-Total-Count"},
AllowCredentials: true,
MaxAge: 86400,
}))
}Depurar problemas de CORS de forma sistemática
Cuando aparece un error de CORS, sigue un enfoque sistemático en lugar de cambiar cabeceras al azar.
interface CORSDebugChecklist {
step: string;
check: string;
fix: string;
}
const debugChecklist: CORSDebugChecklist[] = [
{
step: "1. Check the actual error message",
check: "Browser console shows which header is missing or wrong",
fix: "Add the specific header mentioned in the error",
},
{
step: "2. Inspect the preflight",
check: "Network tab → filter by OPTIONS → check response headers",
fix: "Ensure OPTIONS returns 204 with correct CORS headers",
},
{
step: "3. Verify Vary header",
check: "Response includes 'Vary: Origin'",
fix: "Add Vary header to prevent CDN caching issues",
},
{
step: "4. Check credentials mode",
check: "If using credentials, origin cannot be wildcard",
fix: "Set explicit origin and credentials: true",
},
{
step: "5. Check exposed headers",
check: "Custom response headers readable in JS?",
fix: "Add Access-Control-Expose-Headers for custom headers",
},
{
step: "6. Check Max-Age",
check: "Browser might cache a failed preflight",
fix: "Clear browser cache or use incognito to test",
},
];Ideas clave
CORS es un mecanismo de seguridad, no un obstáculo que sortear. El comodín * solo es apropiado para APIs verdaderamente públicas que sirven datos estáticos sin autenticación. Todo lo demás merece validación explícita de orígenes, un manejo correcto del preflight y la cabecera Vary: Origin para evitar desastres de caché.
Los bugs de CORS más comunes provienen de tres fuentes: reflejar el origen sin validarlo (hace que las credenciales sean inútiles), olvidar Vary: Origin (causa fallos intermitentes relacionados con la CDN) y no manejar el preflight OPTIONS (devuelve 404 o 405 en lugar de 204). Corrige estos tres patrones y eliminarás el 90% de las sesiones de depuración de CORS.


