Guía completa de CORS con Claude Code: APIs cross-origin seguras
Configura CORS con Claude Code: preflight, credentials, allowlist de origin, comandos de prueba y prompts de revisión.
Configurar CORS correctamente con Claude Code
Con un frontend en localhost:3000 y una API en localhost:8787, el navegador ya puede mostrar un error de CORS. La solución rápida suele ser añadir Access-Control-Allow-Origin: *, pero esa configuración es peligrosa cuando la API usa cookies, cabeceras Authorization o un panel de administración.
CORS, Cross-Origin Resource Sharing, es el mecanismo con el que el servidor indica qué otros origins pueden leer sus respuestas desde código JavaScript ejecutado en el navegador. Un origin combina scheme, host y port. https://app.example.com, https://api.example.com, http://localhost:3000 y http://localhost:5173 son origins distintos.
Esta guía separa las decisiones para que Claude Code pueda ayudar sin ocultar los riesgos. Incluye ejemplos copiables para Express, Fastify, Cloudflare Workers y Next.js Route Handler, además de preflight, credentials, allowlist de origin, comandos de prueba y prompts de revisión.
La idea clave: CORS no es autenticación. Solo controla si JavaScript en el navegador puede leer una respuesta cross-origin. No bloquea curl, llamadas servidor a servidor ni usuarios sin permisos. Autenticación, autorización, CSRF, rate limiting y cabeceras de seguridad deben diseñarse aparte.
sequenceDiagram
participant Browser as Browser
participant API as API server
Browser->>API: OPTIONS /api/messages<br/>Origin + Access-Control-Request-*
API-->>Browser: 204 + Access-Control-Allow-*
Browser->>API: POST /api/messages<br/>Cookie or Authorization
API-->>Browser: 200 + Access-Control-Allow-Origin
Decisiones antes de escribir código
Define estos valores antes de pedirle a Claude Code que cree la configuración. Los requisitos vagos suelen producir ejemplos demasiado permisivos para producción.
| Decisión | Ejemplo | Atención |
|---|---|---|
| Origins permitidos | https://app.example.com, https://admin.example.com | Sin rutas ni barra final |
| Credenciales | Cookie, Authorization header | Con cookies revisa también SameSite=None; Secure |
| Métodos | GET,POST,PUT,PATCH,DELETE,OPTIONS | Permite solo los que uses |
| Cabeceras | Content-Type,Authorization,X-Request-ID | Deben coincidir con el preflight |
El preflight es la comprobación que hace el navegador antes de la petición real. Para POST JSON, Authorization, PUT, DELETE y muchas cabeceras personalizadas, primero envía OPTIONS. Si la respuesta no incluye Access-Control-Allow-Methods y Access-Control-Allow-Headers compatibles, la petición real no se envía.
Configuración en Express
El ejemplo asume Node.js 20 o superior. El middleware oficial cors de Express acepta una función en origin, lo que permite validar cada request contra una allowlist. Como la API usa credentials, solo refleja origins permitidos y activa credentials: true.
npm init -y
npm install express cors
node server.mjs
// server.mjs
import express from "express";
import cors from "cors";
const app = express();
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com",
"http://localhost:3000",
"http://localhost:5173",
]);
function isAllowedOrigin(origin) {
if (!origin) return true;
if (allowedOrigins.has(origin)) return true;
return process.env.NODE_ENV !== "production" && /^http:\/\/localhost:\d+$/.test(origin);
}
const corsOptions = {
origin(origin, callback) {
callback(null, isAllowedOrigin(origin));
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-Request-ID"],
exposedHeaders: ["X-Request-ID"],
maxAge: 86400,
optionsSuccessStatus: 204,
};
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && !isAllowedOrigin(origin)) {
return res.status(403).json({ error: "Origin not allowed" });
}
next();
});
app.use(cors(corsOptions));
app.use(express.json());
app.get("/api/health", (_req, res) => {
res.setHeader("X-Request-ID", crypto.randomUUID());
res.json({ ok: true });
});
app.post("/api/messages", (req, res) => {
res.setHeader("X-Request-ID", crypto.randomUUID());
res.json({ ok: true, received: req.body });
});
app.listen(8787, () => {
console.log("API listening on http://localhost:8787");
});
En producción, ejecuta con NODE_ENV=production y deja solo dominios reales en allowedOrigins. Las requests sin header Origin no son CORS de navegador, por eso el ejemplo las deja pasar; API keys, JWT y permisos de usuario siguen siendo responsabilidad del middleware de autenticación.
Configuración en Fastify
Fastify usa @fastify/cors. Su README oficial permite booleanos, strings, arrays, expresiones regulares y funciones para origin, pero una coincidencia exacta con Set es más fácil de auditar. Evita regex amplias salvo que tengas una razón clara.
npm init -y
npm install fastify @fastify/cors
node server.mjs
// server.mjs
import Fastify from "fastify";
import cors from "@fastify/cors";
const app = Fastify({ logger: true });
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com",
"http://localhost:3000",
"http://localhost:5173",
]);
function isAllowedOrigin(origin) {
if (!origin) return true;
if (allowedOrigins.has(origin)) return true;
return process.env.NODE_ENV !== "production" && /^http:\/\/localhost:\d+$/.test(origin);
}
app.addHook("onRequest", async (request, reply) => {
const origin = request.headers.origin;
if (origin && !isAllowedOrigin(origin)) {
return reply.code(403).send({ error: "Origin not allowed" });
}
});
await app.register(cors, {
origin(origin, callback) {
callback(null, isAllowedOrigin(origin));
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-Request-ID"],
exposedHeaders: ["X-Request-ID"],
maxAge: 86400,
strictPreflight: true,
});
app.get("/api/health", async () => ({ ok: true }));
app.post("/api/messages", async (request) => {
return { ok: true, received: request.body };
});
await app.listen({ port: 8787, host: "0.0.0.0" });
En Fastify importa el orden de plugins y hooks. Si un hook de autenticación rechaza OPTIONS antes de que responda el plugin de CORS, el navegador nunca enviará la petición real. Pide a Claude Code que revise también el orden de registro.
Configuración en Cloudflare Workers
Cloudflare Workers expone la API estándar Fetch. Maneja OPTIONS de forma explícita, añade cabeceras CORS tanto en éxito como en error, y usa Vary: Origin cuando la respuesta cambia según el origin.
// src/index.ts
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com",
"http://localhost:3000",
]);
function getCorsHeaders(request: Request): HeadersInit | null {
const origin = request.headers.get("Origin");
if (!origin) return {};
if (!allowedOrigins.has(origin)) return null;
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type,Authorization,X-Request-ID",
"Access-Control-Max-Age": "86400",
"Vary": "Origin",
};
}
export default {
async fetch(request: Request): Promise<Response> {
const corsHeaders = getCorsHeaders(request);
if (corsHeaders === null) {
return Response.json({ error: "Origin not allowed" }, { status: 403 });
}
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
const url = new URL(request.url);
if (url.pathname === "/api/messages" && request.method === "POST") {
const body = await request.json().catch(() => ({}));
return Response.json({ ok: true, received: body }, { headers: corsHeaders });
}
return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders });
},
};
El error habitual en Workers es poner cabeceras solo en el camino exitoso. Si OPTIONS, 401, 403 o 500 no tienen CORS, DevTools puede mostrar solo un fallo CORS y ocultar el error real de la aplicación.
Configuración en Next.js Route Handler
Con App Router, app/api/.../route.ts usa Request y Response estándar. La documentación de Next.js muestra cómo añadir CORS a una respuesta; para APIs con credentials, usa allowlist en vez de *.
// app/api/messages/route.ts
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com",
"http://localhost:3000",
]);
function getCorsHeaders(request: Request): HeadersInit | null {
const origin = request.headers.get("Origin");
if (!origin) return {};
if (!allowedOrigins.has(origin)) return null;
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type,Authorization,X-Request-ID",
"Access-Control-Max-Age": "86400",
"Vary": "Origin",
};
}
export async function OPTIONS(request: Request) {
const headers = getCorsHeaders(request);
if (headers === null) {
return Response.json({ error: "Origin not allowed" }, { status: 403 });
}
return new Response(null, { status: 204, headers });
}
export async function POST(request: Request) {
const headers = getCorsHeaders(request);
if (headers === null) {
return Response.json({ error: "Origin not allowed" }, { status: 403 });
}
const body = await request.json().catch(() => ({}));
return Response.json({ ok: true, received: body }, { headers });
}
headers() en next.config.js sirve para cabeceras estáticas de APIs públicas. Si el origin debe evaluarse por request, es más claro hacerlo dentro del Route Handler.
Comandos de prueba
Usa curl para separar preflight y petición real. Comprueba que Access-Control-Allow-Origin coincide exactamente con el Origin enviado y que Access-Control-Allow-Credentials: true aparece solo para origins permitidos.
curl -i -X OPTIONS http://localhost:8787/api/messages \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization"
curl -i -X POST http://localhost:8787/api/messages \
-H "Origin: http://localhost:3000" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer dev-token" \
--data '{"text":"hello"}'
curl -i -X OPTIONS http://localhost:8787/api/messages \
-H "Origin: https://evil.example" \
-H "Access-Control-Request-Method: POST"
En el navegador, una prueba con credentials se ve así. Si usas credentials: "include", el navegador rechazará una respuesta CORS con wildcard.
await fetch("http://localhost:8787/api/messages", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer dev-token",
},
body: JSON.stringify({ text: "hello" }),
});
Casos de uso reales
El primer caso es una SPA y una API en dominios distintos. Si React vive en https://app.example.com y la API en https://api.example.com, necesitas una allowlist explícita. Con cookies de login, revisa credentials, atributos de cookie y CSRF juntos.
El segundo caso es un frontend de administración. Puedes añadir https://admin.example.com a la allowlist, pero CORS no sustituye la autorización de administrador. Esa lógica debe estar en la API.
El tercer caso es un Cloudflare Worker como BFF o proxy ligero. El navegador habla con el Worker, y el Worker llama al upstream. La respuesta del Worker hacia el navegador sigue necesitando CORS correcto.
El cuarto caso es una API pública de solo lectura. Si no tiene cookies, Authorization ni datos privados, Access-Control-Allow-Origin: * puede ser aceptable. Si habrá autenticación en el futuro, empieza con allowlist.
Errores concretos
| Error | Resultado | Corrección |
|---|---|---|
Combinar * con credentials: true | El navegador bloquea la respuesta | Devuelve el origin explícito |
Registrar https://app.example.com/ | La barra final rompe la coincidencia | Guarda https://app.example.com |
Permitir solo localhost | Fallan puertos distintos | Usa http://localhost:3000 |
Exigir auth para OPTIONS | Preflight se detiene en 401/403 | Procesa preflight antes de auth |
| Omitir CORS en errores | DevTools oculta el error real | Añade cabeceras a 4xx/5xx |
| CDN cachea cabeceras por origin | Se mezclan cabeceras entre origins | Añade Vary: Origin |
| Tratar CORS como autorización | Clientes no navegador siguen llamando | Implementa auth y CSRF aparte |
MDN es claro: las requests CORS con credentials no pueden usar Access-Control-Allow-Origin: *. Si Claude Code genera esa combinación, trátala como un bug.
Prompts de revisión para Claude Code
Review this repository's CORS configuration.
Check:
- No Access-Control-Allow-Origin: * when credentials are enabled
- Allowlist uses exact scheme/host/port matching
- OPTIONS preflight runs before authentication middleware
- 4xx/5xx responses include the required CORS headers
- Vary: Origin is present when responses vary by origin
If changes are needed, propose the smallest safe diff.
Diagnose this CORS error by cause.
Browser error:
<paste the full DevTools Console message>
curl preflight:
<paste curl -i -X OPTIONS output>
Expected origin:
https://app.example.com
Read the relevant API files and return reproduction steps, root cause, fix, and tests.
Review the Express/Fastify/Next.js/Workers CORS implementation as a security reviewer.
Focus on:
- Whether request origins are blindly reflected
- Whether localhost remains enabled in production
- Whether Authorization is allowed without proper authorization checks
- Whether cookie flows mention SameSite=None; Secure and CSRF protection
- Whether test commands separate preflight and the real request
Group findings as Critical, Must fix, and Improvement.
Referencias y enlaces internos
Usa la guía de CORS de MDN como base. Para implementar, consulta Express cors middleware, @fastify/cors, ejemplos CORS de Cloudflare Workers y Next.js Route Handlers. Para flujos reutilizables en Claude Code, revisa Claude Code commands.
También conviene leer la guía de desarrollo de API, la guía de cabeceras de seguridad web, la guía de Cloudflare Workers y la checklist de revisión de código.
Siguiente paso
Después de sustituir los dominios de ejemplo por los tuyos, ejecuta los prompts de revisión en tu repositorio y usa la guía de buenas prácticas de seguridad con Claude Code para revisar cookies, CSRF, autorización y cabeceras en conjunto. En proyectos de cliente o plataformas internas, esta checklist puede convertirse en un artefacto de revisión que facilita proponer soporte de implementación o plantillas reutilizables.
Resultado de probarlo en la práctica
En la prueba local de Masa, los ejemplos de Express y Fastify corrieron en localhost:8787; el preflight y el POST desde Origin: http://localhost:3000 funcionaron, mientras https://evil.example devolvió 403. Los olvidos más fáciles fueron las cabeceras CORS en errores y el manejo explícito de OPTIONS en Workers. El flujo más estable fue implementar la allowlist, ejecutar los curl, y pedir a Claude Code que confirme que no existe la combinación wildcard más credentials, que Vary: Origin aparece donde corresponde y que localhost no llega a producción.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.