StackPractices
intermediate Por Mathias Paulenko

Configurar CORS en Express, Flask y Spring Boot

Cómo configurar los encabezados de Cross-Origin Resource Sharing (CORS) correctamente para APIs, SPAs y funciones serverless sin abrir agujeros de seguridad.

Temas: api

Visión General

Antes de que un origen pueda leer la respuesta de otro, el navegador consulta CORS (Cross-Origin Resource Sharing). Un CORS mal configurado es una de las fuentes más habituales de fricción entre frontend y backend, y también de agujeros de seguridad reales. Lo que sigue configura el middleware de CORS como toca: validación de la lista de orígenes, manejo del preflight, credenciales y declaración explícita de métodos y encabezados, en Python, JavaScript y Java.

Cuándo Usar

Usa esta receta cuando:

  • Tu frontend (una SPA, app móvil o widget de terceros) corre en un origen distinto al de tu API.
  • Necesitas aceptar peticiones cross-origin autenticadas con cookies o encabezados de autorización.
  • Construyes una API pública consumida por dominios externos.
  • Persigues un críptico error “blocked by CORS policy” en la consola del navegador.

Solución

Versiones ejecutables de estos ejemplos, más un script curl que verifica los encabezados, están en el repositorio complementario.

Python (Flask)

from flask import Flask, request, make_response

app = Flask(__name__)

ALLOWED_ORIGINS = {
    "https://app.example.com",
    "https://admin.example.com",
    "http://localhost:3000",
}
ALLOWED_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"]
ALLOWED_HEADERS = ["Content-Type", "Authorization", "X-Request-ID"]
ALLOW_CREDENTIALS = True

@app.after_request
def add_cors_headers(response):
    origin = request.headers.get("Origin")

    # Solo refleja orígenes permitidos; nunca uses "*" con credenciales
    if origin in ALLOWED_ORIGINS:
        response.headers["Access-Control-Allow-Origin"] = origin
        response.headers["Vary"] = "Origin"

    if ALLOW_CREDENTIALS:
        response.headers["Access-Control-Allow-Credentials"] = "true"

    return response

@app.route("/api/<path:path>", methods=["OPTIONS"])
def handle_preflight(path):
    origin = request.headers.get("Origin")
    if origin not in ALLOWED_ORIGINS:
        return make_response(("", 204))  # Sin headers CORS para orígenes no permitidos

    response = make_response("", 204)
    response.headers["Access-Control-Allow-Origin"] = origin
    response.headers["Access-Control-Allow-Methods"] = ", ".join(ALLOWED_METHODS)
    response.headers["Access-Control-Allow-Headers"] = ", ".join(ALLOWED_HEADERS)
    response.headers["Access-Control-Allow-Credentials"] = "true"
    response.headers["Access-Control-Max-Age"] = "86400"
    return response

JavaScript (Express)

import express from "express";

const app = express();

const ALLOWED_ORIGINS = new Set([
  "https://app.example.com",
  "https://admin.example.com",
  "http://localhost:3000",
]);

function corsMiddleware(req, res, next) {
  const origin = req.headers.origin;

  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.header("Access-Control-Allow-Origin", origin);
    res.header("Vary", "Origin");
  }

  res.header("Access-Control-Allow-Credentials", "true");

  // Petición de preflight
  if (req.method === "OPTIONS") {
    if (origin && !ALLOWED_ORIGINS.has(origin)) {
      return res.sendStatus(204);
    }
    res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH");
    res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID");
    res.header("Access-Control-Max-Age", "86400");
    return res.sendStatus(204);
  }

  next();
}

app.use(corsMiddleware);
app.use(express.json());

// Alternativa: el paquete cors con lista explícita de orígenes
// import cors from "cors";
// app.use(cors({
//   origin: (origin, callback) => {
//     if (!origin || ALLOWED_ORIGINS.has(origin)) {
//       callback(null, true);
//     } else {
//       callback(new Error("Not allowed by CORS"));
//     }
//   },
//   credentials: true,
//   methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
//   allowedHeaders: ["Content-Type", "Authorization", "X-Request-ID"],
// }));

Java (Spring Boot)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig {

  private static final String[] ALLOWED_ORIGINS = {
    "https://app.example.com",
    "https://admin.example.com",
    "http://localhost:3000"
  };

  @Bean
  public WebMvcConfigurer corsConfigurer() {
    return new WebMvcConfigurer() {
      @Override
      public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
          .allowedOrigins(ALLOWED_ORIGINS)
          .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH")
          .allowedHeaders("Content-Type", "Authorization", "X-Request-ID")
          .allowCredentials(true)
          .maxAge(86400);
      }
    };
  }
}

// Integración con Spring Security (si usas SecurityFilterChain)
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.cors(cors -> {})
        .csrf(csrf -> csrf.disable()) // solo si la API es stateless con tokens
        .authorizeHttpRequests(auth -> auth
          .requestMatchers("/api/**").authenticated()
          .anyRequest().permitAll()
        );
    // Para patrones de autenticación, consulta la guía de seguridad de APIs de StackPractices.
    return http.build();
  }
}

Explicación

  • Same-Origin Policy: los navegadores bloquean por defecto las peticiones de origin-a.com a origin-b.com, y CORS es la forma controlada de relajar esa política.
  • Preflight (OPTIONS): el navegador manda una petición de preflight antes de los métodos no simples (PUT, DELETE, PATCH) o encabezados personalizados, y el servidor tiene que responder con los orígenes, métodos y encabezados permitidos.
  • Access-Control-Allow-Origin quiere un origen literal (https://app.example.com), y el único atajo disponible es *. Nunca combines * con Access-Control-Allow-Credentials: true, porque el navegador rechaza esa combinación directamente.
  • Recurre a Vary: Origin siempre que los encabezados CORS dependan de quién pregunta. Sin él, un CDN puede cachear una respuesta con el encabezado de un origen y servirla a peticiones de otro.
  • Access-Control-Allow-Credentials es el interruptor que deja viajar a las cookies y a los encabezados Authorization en peticiones cross-origin. Tienen que activarlo tanto el cliente (credentials: 'include' en fetch, withCredentials: true en axios) como el servidor.

La secuencia de la petición queda así:

sequenceDiagram diagram: participant B as Navegador

El preflight solo ocurre en peticiones no simples. Un GET plano con encabezados estándar lo salta y va directo a la llamada real.

Variantes

EnfoqueConfiguraciónIdeal para
Lista de orígenesLista explícita de orígenesAPIs de producción con consumidores conocidos
Patrón regex*.example.comComodines de subdominio (valida con cuidado)
Origen dinámicoOrigen validado en cada peticiónAPIs multi-tenant con orígenes por tenant
Comodín *Sin restricción de origenAPIs públicas de solo lectura sin credenciales
ProxyEl frontend proxyea a la APIDesarrollo y despliegues en el mismo origen

Lo Que Funciona

  1. Nunca uses * con credenciales: el navegador rechaza Access-Control-Allow-Origin: * en cuanto aparece Allow-Credentials: true, así que refleja el origen de la petición tras comprobarlo contra tu lista.
  2. Valida los orígenes explícitamente: una lista guarda coincidencias exactas, nada más difuso. Las regex permisivas son peligrosas aquí: un *.example.com descuidado también acepta attacker-example.com.
  3. Añade Vary: Origin: sin él, una caché compartida servirá tan contenta la respuesta del dominio A a una petición del dominio B.
  4. Mantén un max-age de preflight razonable: 86400 segundos (un día) es un valor típico; mucho más y los cambios de política tardan en propagarse, mucho menos y desperdicias peticiones de preflight.
  5. Restringe métodos y encabezados permitidos: declara solo lo que la API soporta de verdad, porque una política demasiado permisiva solo agranda la superficie de ataque.

Errores Comunes

  1. Poner Access-Control-Allow-Origin: * y preguntarse por qué las cookies no viajan cross-origin.
  2. Reflejar el encabezado Origin sin validar, lo que deja la API abierta a cualquier sitio web.
  3. Olvidar responder al preflight OPTIONS, lo que produce errores CORS en peticiones PUT y DELETE.
  4. No enviar Vary: Origin, permitiendo que un CDN cachee la respuesta de un origen y la sirva a otro.
  5. Activar allowCredentials en una API pública sin validar orígenes, lo que expone tus endpoints autenticados a cualquier sitio malicioso. Consulta el Checklist de Seguridad de APIs para la validación de orígenes.

Solución de Problemas

  • El preflight devuelve 204 pero el navegador sigue bloqueando: un 204 pelado suspende el preflight si la respuesta OPTIONS no lleva también los encabezados Access-Control-Allow-*.
  • Access-Control-Allow-Origin no coincide: un origen es scheme + host + port, así que https://www.app.com y https://app.com son orígenes distintos, igual que http y https. Registra el encabezado Origin entrante y compáralo byte a byte con tu lista.
  • El CDN sirve la respuesta de otro origen: sin Vary: Origin, una caché compartida puede servir a un origen B la respuesta cacheada del origen A. Si validas orígenes dinámicamente, pon además Cache-Control: no-store en las respuestas de preflight.
  • Las subidas de archivos quedan bloqueadas: un multipart/form-data dispara preflight, así que falla salvo que POST esté en Access-Control-Allow-Methods y Content-Type en Access-Control-Allow-Headers. Para subidas con URLs prefirmadas de S3, el bucket necesita su propia regla CORS, porque la petición ni siquiera llega a tu servidor.
  • Fallan las conexiones SSE o WebSocket: EventSource no puede mandar encabezados personalizados, así que autentica con cookies más withCredentials o con un token en el query string. Los WebSockets se saltan CORS por completo; ahí valida el encabezado Origin durante el handshake.
  • El intercambio del token OAuth queda bloqueado: las redirecciones no pasan por CORS, pero el fetch que cambia el código de autorización por un token sí. El endpoint de tokens del proveedor tiene que devolver Access-Control-Allow-Origin para tu dominio (Google y GitHub permiten todos los orígenes; un proveedor propio hay que configurarlo).

Ver También

Preguntas frecuentes

¿Por qué mi API funciona en Postman pero falla en el navegador?

Postman se salta la Same-Origin Policy por completo porque no es un navegador. El navegador descarta la respuesta de una petición cross-origin si el servidor no manda los encabezados Access-Control-Allow-* correctos. En lugar de adivinar, reproduce las comprobaciones del navegador con curl y un encabezado Origin explícito.

¿Puedo usar un comodín para subdominios como *.example.com?

El encabezado no admite patrones: Access-Control-Allow-Origin quiere la cadena exacta del origen. Lo que sí puedes hacer es validar en vivo: comprobar si el origen de la petición termina en .example.com y reflejar el valor exacto de vuelta. Spring Boot lo expone como allowedOriginPatterns; en Express o Flask escribes la comprobación tú mismo. Ojo con el bypass del punto final: evil-example.com debería fallar una validación bien hecha.

¿Necesito CORS si el frontend y la API comparten dominio?

No. CORS solo importa cuando difiere el origen (scheme + host + port). Viviendo ambos en https://example.com, no hace falta configurar nada. Enrutar /api a través de un reverse proxy es la configuración habitual de mismo origen cuando el backend está en otro sitio.

¿Cómo depuro un error de CORS?

Abre DevTools y mira la petición fallida en la pestaña Network; compara el encabezado de respuesta Access-Control-Allow-Origin con el Origin que envió el navegador. Si el fallo es de preflight, inspecciona la respuesta del OPTIONS. No necesitas un navegador para comprobar ninguno de los dos casos: curl -H "Origin: https://yourapp.com" -X OPTIONS https://api.example.com/endpoint hace el trabajo.

¿Cómo permito cookies en peticiones cross-origin?

Activa Access-Control-Allow-Credentials: true en el servidor y credentials: 'include' (fetch) o withCredentials: true (axios) en el cliente. El allow-origin tiene que ser entonces un origen exacto, porque * se rechaza. Las cookies además necesitan su propio SameSite=None; Secure, o el navegador las descarta cross-site igualmente.

¿Cómo configuro CORS en funciones serverless?

Devuelve los encabezados CORS desde la respuesta de la propia función y contesta al OPTIONS con un 204 más los encabezados permitidos. Con API Gateway + Lambda puedes configurarlo a nivel de gateway. Lo configures al nivel que lo configures, mira la respuesta en vivo y confirma que los encabezados están ahí.

¿Los WebSockets usan CORS?

No. Los handshakes de WebSocket quedan fuera de la Same-Origin Policy, así que la vigilancia te toca a ti: el servidor debe revisar Origin durante el upgrade y descartar lo inesperado. CORS tampoco sustituye a la autenticación aquí: mete un token en la URL o en el subprotocolo.

¿Cómo manejo CORS detrás de un API gateway en microservicios?

Configúralo una sola vez en el gateway (Kong, Envoy y AWS API Gateway gestionan el preflight y estampan los encabezados) en lugar de en cada servicio. Un servicio al que se llega sin pasar por el gateway sigue necesitando su propio manejo de CORS. Las llamadas internas entre servicios no pasan por un navegador, así que CORS ni siquiera aplica ahí.

¿Cómo gestiono los orígenes permitidos por entorno?

Guarda la lista en una variable de entorno como CORS_ORIGINS=https://yourapp.com,https://www.yourapp.com, léela al arrancar y valida cada Origin entrante contra ella. Hardcodear orígenes en el código hace que promocionar entre entornos sea propenso a errores.

¿Puedo verificar mi configuración CORS sin navegador?

Sí. Una petición simple es solo curl -H "Origin: https://example.com" -I https://api.example.com/endpoint; añade -X OPTIONS -H "Access-Control-Request-Method: PUT" para sondear el preflight. Si quieres cobertura end-to-end, un test de Playwright puede lanzar peticiones cross-origin desde un contexto de navegador real.