Complete CORS Configuration Guide with Claude Code
Implement safe CORS with Claude Code: preflight, credentials, origin allowlists, tests, and review prompts.
Configure CORS Correctly With Claude Code
Running a frontend on localhost:3000 and an API on localhost:8787 is enough to trigger a CORS error. The tempting fix is to add Access-Control-Allow-Origin: *, but that becomes unsafe as soon as the API uses cookies, authorization headers, or an admin dashboard.
CORS, or Cross-Origin Resource Sharing, is the browser-controlled mechanism that lets a server decide which other origins may read its responses. An origin is the combination of scheme, host, and port. https://app.example.com, https://api.example.com, http://localhost:3000, and http://localhost:5173 are all different origins.
This guide breaks the work into reviewable pieces so Claude Code can help without hiding the security decisions. You will get copy-pasteable examples for Express, Fastify, Cloudflare Workers, and a Next.js Route Handler, plus preflight checks, credential rules, allowlist patterns, test commands, and Claude Code review prompts.
The key mindset shift is that CORS is not authentication. It controls whether browser JavaScript can read a cross-origin response. It does not stop curl, server-to-server calls, or unauthorized users. Keep API authentication, authorization, CSRF protection, rate limiting, and security headers as separate controls.
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
Decisions Before Code
Decide these values before asking Claude Code to write CORS middleware. Vague prompts often produce permissive examples that are fine for demos but risky in production.
| Decision | Example | Watch for |
|---|---|---|
| Allowed origins | https://app.example.com, https://admin.example.com | No paths and no trailing slash |
| Credentials | Cookie, Authorization header | Cookie flows also need SameSite=None; Secure |
| Methods | GET,POST,PUT,PATCH,DELETE,OPTIONS | Allow only what the API uses |
| Request headers | Content-Type,Authorization,X-Request-ID | Must match the preflight request |
A preflight request is the browser’s permission check before the real request. For JSON POST, Authorization, PUT, DELETE, and many custom headers, the browser first sends OPTIONS. If the response does not include matching Access-Control-Allow-Methods and Access-Control-Allow-Headers, the browser never sends the real request.
Express Configuration
This example targets Node.js 20 or later. The official Express cors middleware accepts a function for origin, so we can evaluate each request against an allowlist. Because the API supports credentials, it reflects only allowed origins and sets 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");
});
In production, run with NODE_ENV=production and keep only real application origins in allowedOrigins. Requests without an Origin header are not browser CORS requests, so this sample lets them through; API keys, JWT checks, and user permissions still belong in normal authentication middleware.
Fastify Configuration
Fastify uses @fastify/cors. Its official README supports booleans, strings, arrays, regular expressions, and functions for origin, but a Set-based exact match is easier to audit. Avoid broad regular expressions unless you have a strong reason.
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" });
Plugin and hook order matters in Fastify. If an auth hook rejects OPTIONS before the CORS plugin can answer, the browser will block the real request. Ask Claude Code to inspect plugin registration order, not just header names.
Cloudflare Workers Configuration
Cloudflare Workers expose the standard Fetch API. Handle OPTIONS explicitly, return CORS headers on success and error responses, and add Vary: Origin when the response differs by 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 });
},
};
The common Workers mistake is adding headers only to the happy path. If OPTIONS, 401, 403, or 500 responses omit CORS headers, DevTools may show only a CORS failure while hiding the real application error.
Next.js Route Handler Configuration
With the App Router, app/api/.../route.ts uses Web Request and Response objects. The Next.js docs show how to add CORS headers to a response; for credentialed APIs, use an allowlist instead of *.
// 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 });
}
next.config.js can attach static headers, but per-origin credentialed APIs are easier to review inside the route handler. Use config-level headers for static, public API responses only.
Test Commands
Use curl to separate preflight from the real request. Confirm that Access-Control-Allow-Origin exactly matches the request Origin, and that Access-Control-Allow-Credentials: true appears only for allowed origins.
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"
Browser credential checks look like this. If credentials: "include" is used, a wildcard CORS response will be rejected by the browser.
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" }),
});
Practical Use Cases
The first common case is a SPA and API on separate domains. A React app on https://app.example.com and an API on https://api.example.com need an explicit allowlist. If login cookies are involved, review credentials, cookie attributes, and CSRF protection together.
The second case is an admin frontend. Add https://admin.example.com to the allowlist, but do not treat CORS as the admin permission check. Authorization still belongs in API code.
The third case is a Cloudflare Worker used as a BFF or lightweight proxy. The browser talks to the Worker, and the Worker talks to the upstream API. The Worker response still needs correct CORS headers.
The fourth case is a public read-only API. If it has no cookies, no authorization, and no private data, Access-Control-Allow-Origin: * can be acceptable. If authentication may be added later, start with an allowlist.
Specific Pitfalls
| Pitfall | Result | Fix |
|---|---|---|
Combining * with credentials: true | Browser blocks the response | Return the explicit origin |
Registering https://app.example.com/ | Trailing slash prevents a match | Store only https://app.example.com |
Allowing only localhost | Different ports fail | Include http://localhost:3000 |
Requiring auth for OPTIONS | Preflight stops at 401/403 | Handle preflight before auth |
| Missing CORS on errors | DevTools hides the real error | Add CORS headers to 4xx/5xx too |
| CDN caches origin-specific headers | Headers leak across origins | Add Vary: Origin |
| Treating CORS as authorization | Non-browser clients still call the API | Implement auth and CSRF separately |
MDN is explicit: credentialed CORS requests cannot use Access-Control-Allow-Origin: *. If Claude Code produces that combination, treat it as a bug.
Claude Code Review Prompts
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.
References and Related Guides
Use MDN’s CORS guide as the baseline. For implementation details, see Express cors middleware, @fastify/cors, Cloudflare Workers CORS examples, and Next.js Route Handlers. For reusable Claude Code workflows, check Claude Code commands.
Read this alongside the API development guide, web security headers guide, Cloudflare Workers guide, and code review workflow checklist.
Next Step
After replacing the sample domains with your own, run the review prompts against your repository and then use the Claude Code security best practices guide to review cookies, CSRF, authorization, and headers together. For client work or internal platform teams, this checklist can become a review artifact that supports paid implementation help or reusable template adoption.
Results From Actually Trying This
In Masa’s local test, the Express and Fastify samples ran on localhost:8787; preflight and POST succeeded from Origin: http://localhost:3000, while https://evil.example returned 403. The easiest misses were CORS headers on error responses and explicit OPTIONS handling in Workers. The most reliable workflow was to implement the allowlist first, run the curl checks, and then ask Claude Code to verify that there is no wildcard-plus-credentials combination, that Vary: Origin is present where needed, and that localhost cannot leak into production.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.