DevToolBox무료
블로그

CORS 테스터: CORS 오류 수정 및 크로스 오리진 요청 구성 — 완전 가이드

14분 읽기by DevToolBox

TL;DR

CORS errors occur when a browser blocks a cross-origin request because the server did not return the right Access-Control-Allow-* headers. Fix them by adding the correct headers on the server — never on the client. Use the cors npm package for Express, the headers() config in Next.js, add_header directives in Nginx, and CORSMiddleware in FastAPI. For credentials (cookies), never use * as the origin. Use our online CORS tester to validate your headers instantly.

What Is CORS? The Browser Security Model Explained

CORS (Cross-Origin Resource Sharing) is a W3C standard that governs how browsers handle HTTP requests initiated from one origin to a different origin. It is an extension — and controlled relaxation — of the Same-Origin Policy (SOP), which has been a fundamental browser security boundary since the Netscape era.

An origin is precisely defined as the triplet of:

  • Scheme (protocol): http vs https
  • Host: example.com vs api.example.com
  • Port: :3000 vs :8080 (default ports 80/443 are implicit)

Two URLs are same-origin only if all three components match exactly. Consider these examples:

URL PairSame Origin?Reason
https://example.com vs https://example.com/apiYesSame scheme, host, port; path differs
https://example.com vs http://example.comNoDifferent scheme (https vs http)
https://app.example.com vs https://api.example.comNoDifferent subdomain
https://example.com:3000 vs https://example.com:4000NoDifferent port

Without CORS, if you visited evil.com, its JavaScript could call https://yourbank.com/api/transfer using your session cookies, potentially draining your account. The Same-Origin Policy prevents this by blocking cross-origin requests from JavaScript. CORS provides a controlled mechanism for servers to explicitly opt-in to cross-origin access.

Simple Requests vs Preflight Requests — When Does the Browser Send OPTIONS?

The browser uses different CORS handling depending on the request characteristics. Understanding this distinction is critical for debugging CORS issues.

Simple Requests

A request is considered simple (no preflight) if ALL of these conditions are true:

  • Method is GET, POST, or HEAD
  • No custom request headers (beyond the CORS-safelisted headers)
  • Content-Type (if present) is one of: application/x-www-form-urlencoded, multipart/form-data, or text/plain
  • No ReadableStream in the request body

For simple requests, the browser sends the request directly with an Origin header. The server must respond with Access-Control-Allow-Origin. If missing, the browser blocks the response from JavaScript — but the request was made and the server processed it.

Preflight Requests (OPTIONS)

Anything outside the simple request criteria triggers a preflight: the browser automatically sends an HTTP OPTIONS request before the actual request. Common triggers:

  • Method is PUT, DELETE, PATCH, or CONNECT
  • Custom headers like Authorization, X-API-Key, X-Request-ID
  • Content-Type: application/json (the most common case — JSON APIs)
# Preflight request the browser sends automatically
OPTIONS /api/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

# Server must respond with:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400       # cache preflight for 24 hours

# Only after successful preflight does the browser send the actual request:
POST /api/users HTTP/1.1
Origin: https://app.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGci...

{"name": "Alice", "email": "alice@example.com"}

The Access-Control-Max-Age header is critical for performance — it tells the browser how long (in seconds) to cache the preflight result. Without it, the browser sends an OPTIONS request before every single API call. Set it to 86400 (24 hours) or higher for production.

CORS Response Headers — Complete Reference

Servers communicate CORS permissions through response headers. Here is every CORS response header explained:

HeaderPurposeExample Value
Access-Control-Allow-OriginWhich origins can read the responsehttps://app.example.com or *
Access-Control-Allow-MethodsHTTP methods allowed for cross-origin requestsGET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-HeadersRequest headers the client is allowed to sendContent-Type, Authorization, X-API-Key
Access-Control-Allow-CredentialsWhether cookies/auth can be includedtrue (cannot use * as origin)
Access-Control-Max-AgeSeconds to cache preflight response86400
Access-Control-Expose-HeadersNon-simple headers exposed to JavaScriptX-Total-Count, X-Request-ID

Access-Control-Expose-Headers is often overlooked. By default, JavaScript can only read a small set of “simple” response headers (Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma). Any custom response headers like pagination metadata (X-Total-Count) or trace IDs (X-Request-ID) must be explicitly exposed.

CORS Error Types and How to Diagnose Each

1. Missing Access-Control-Allow-Origin Header

# Browser console error:
Access to fetch at 'https://api.example.com/users' from origin
'https://app.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

# Diagnosis: Server is not sending CORS headers at all.
# Fix: Add CORS middleware or headers to the server.

2. Origin Not Allowed

# Browser console error:
The 'Access-Control-Allow-Origin' header has a value 'https://prod.example.com'
that is not equal to the supplied origin 'https://staging.example.com'.

# Diagnosis: Server has CORS configured, but only allows a specific origin,
# and the requesting origin doesn't match.
# Fix: Add the requesting origin to the allowed list.

3. Credentials with Wildcard Origin

# Browser console error:
The value of the 'Access-Control-Allow-Origin' header in the response must not be
the wildcard '*' when the request's credentials mode is 'include'.

# Diagnosis: Server returns *, but client uses credentials: 'include'.
# Fix: Change Access-Control-Allow-Origin to the specific origin,
# and add Access-Control-Allow-Credentials: true.

4. Preflight Request Failure

# Browser console error:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present.

# OR:
Method PUT is not allowed by Access-Control-Allow-Methods in preflight response.

# Diagnosis: Server does not handle OPTIONS requests or doesn't include
# the requested method/header in the preflight response.
# Fix: Handle OPTIONS and return complete Access-Control-Allow-* headers.

5. Header Not Allowed in Preflight

# Browser console error:
Request header field Authorization is not allowed by
Access-Control-Allow-Headers in preflight response.

# Diagnosis: The Access-Control-Allow-Headers list does not include
# the header the client is sending.
# Fix: Add Authorization (and any other custom headers) to
# Access-Control-Allow-Headers.

Node.js / Express CORS Configuration

The cors npm package is the standard solution for Express and Node.js HTTP servers. It handles preflight automatically and gives you a clean options API.

npm install cors
npm install --save-dev @types/cors
import express from 'express';
import cors from 'cors';

const app = express();

// ─── Option 1: Allow a single specific origin ───────────────────────────────
app.use(cors({
  origin: 'https://app.example.com',
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
  credentials: true,                 // allow cookies
  maxAge: 86400,                     // cache preflight 24h
}));

// ─── Option 2: Allow multiple origins ───────────────────────────────────────
const allowedOrigins = [
  'https://app.example.com',
  'https://admin.example.com',
  'http://localhost:3000',           // dev only — remove in prod
];

app.use(cors({
  origin: (origin, callback) => {
    // Allow requests with no origin (mobile apps, Postman, curl)
    if (!origin) return callback(null, true);
    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`CORS: origin ${origin} not allowed`));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
}));

// ─── Option 3: Wildcard (no credentials, open API) ──────────────────────────
app.use(cors());  // equivalent to origin: '*'

// ─── Handle preflight for all routes explicitly ──────────────────────────────
// (cors middleware does this automatically, but explicit is clearer)
app.options('*', cors());

app.get('/api/users', (req, res) => {
  res.json({ users: [] });
});

Key points when using the cors package:

  • Always set credentials: true alongside a specific origin (never *) for cookie-based auth.
  • The origin function receives undefined for requests with no Origin header (non-browser clients). Return true for these — they are not cross-origin requests.
  • app.options('*', cors()) ensures preflight is handled even before route-specific middleware.
  • Set maxAge to avoid repeated preflight requests from the same browser.

Next.js CORS — API Routes, Middleware, and App Router

App Router Route Handlers (Next.js 13+)

// src/app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';

const ALLOWED_ORIGINS = ['https://app.example.com', 'http://localhost:3000'];

function corsHeaders(origin: string | null) {
  const headers: Record<string, string> = {
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    'Access-Control-Max-Age': '86400',
  };
  if (origin && ALLOWED_ORIGINS.includes(origin)) {
    headers['Access-Control-Allow-Origin'] = origin;
    headers['Access-Control-Allow-Credentials'] = 'true';
  }
  return headers;
}

// Handle preflight
export async function OPTIONS(req: NextRequest) {
  const origin = req.headers.get('origin');
  return new NextResponse(null, {
    status: 204,
    headers: corsHeaders(origin),
  });
}

export async function GET(req: NextRequest) {
  const origin = req.headers.get('origin');
  return NextResponse.json(
    { users: [] },
    { headers: corsHeaders(origin) }
  );
}

export async function POST(req: NextRequest) {
  const origin = req.headers.get('origin');
  const body = await req.json();
  return NextResponse.json(
    { created: true, data: body },
    { status: 201, headers: corsHeaders(origin) }
  );
}

Next.js Middleware (Apply CORS Globally)

// middleware.ts (at project root, next to package.json)
import { NextRequest, NextResponse } from 'next/server';

const ALLOWED_ORIGINS = ['https://app.example.com', 'http://localhost:3000'];

export function middleware(req: NextRequest) {
  const origin = req.headers.get('origin') ?? '';
  const isAllowed = ALLOWED_ORIGINS.includes(origin);

  // Handle preflight
  if (req.method === 'OPTIONS') {
    return new NextResponse(null, {
      status: 204,
      headers: {
        'Access-Control-Allow-Origin': isAllowed ? origin : '',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        'Access-Control-Allow-Credentials': 'true',
        'Access-Control-Max-Age': '86400',
      },
    });
  }

  const response = NextResponse.next();
  if (isAllowed) {
    response.headers.set('Access-Control-Allow-Origin', origin);
    response.headers.set('Access-Control-Allow-Credentials', 'true');
  }
  return response;
}

export const config = {
  matcher: '/api/:path*',  // only apply to API routes
};

next.config.js Static Headers

// next.config.js — applies CORS headers to all matched paths
// Note: only works for single static origin; use middleware for dynamic origins
/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          { key: 'Access-Control-Allow-Origin', value: 'https://app.example.com' },
          { key: 'Access-Control-Allow-Methods', value: 'GET, POST, PUT, DELETE, OPTIONS' },
          { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
          { key: 'Access-Control-Allow-Credentials', value: 'true' },
          { key: 'Access-Control-Max-Age', value: '86400' },
        ],
      },
    ];
  },
};

module.exports = nextConfig;

Nginx CORS Configuration

When Nginx is used as a reverse proxy, you can handle CORS at the proxy layer. This is especially useful when you cannot modify the backend application code.

# /etc/nginx/sites-available/api.example.com

server {
    listen 443 ssl;
    server_name api.example.com;

    location /api/ {
        # Handle preflight OPTIONS requests
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin'  'https://app.example.com' always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-API-Key' always;
            add_header 'Access-Control-Allow-Credentials' 'true' always;
            add_header 'Access-Control-Max-Age'        '86400' always;
            add_header 'Content-Type'                  'text/plain; charset=utf-8';
            add_header 'Content-Length'                '0';
            return 204;
        }

        # Add CORS headers to actual responses
        add_header 'Access-Control-Allow-Origin'      'https://app.example.com' always;
        add_header 'Access-Control-Allow-Methods'     'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers'     'Content-Type, Authorization' always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;

        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Multiple Origins with Nginx Map

# nginx.conf (http block)
map $http_origin $cors_origin {
    default "";
    "https://app.example.com"    "https://app.example.com";
    "https://admin.example.com"  "https://admin.example.com";
    "http://localhost:3000"      "http://localhost:3000";
}

# In server block:
server {
    location /api/ {
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin'  $cors_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
            add_header 'Access-Control-Allow-Credentials' 'true' always;
            add_header 'Access-Control-Max-Age' '86400' always;
            return 204;
        }

        add_header 'Access-Control-Allow-Origin'      $cors_origin always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;

        proxy_pass http://localhost:8080;
    }
}

The always modifier ensures CORS headers are added even for error responses (4xx, 5xx). Without it, error responses from the upstream server will lack CORS headers, and the browser will not be able to read the error body — showing a generic network error instead.

Python Flask and FastAPI CORS Configuration

FastAPI CORSMiddleware

# pip install fastapi uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

# ─── Single or multiple origins ────────────────────────────────────────────
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "https://app.example.com",
        "https://admin.example.com",
        "http://localhost:3000",        # dev only
    ],
    allow_credentials=True,            # needed for cookies/auth
    allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
    allow_headers=["Content-Type", "Authorization", "X-API-Key"],
    max_age=86400,                     # preflight cache 24h
    expose_headers=["X-Total-Count", "X-Request-ID"],  # expose custom headers
)

@app.get("/api/users")
async def get_users():
    return {"users": []}

@app.post("/api/users")
async def create_user(user: dict):
    return {"created": True, "user": user}

# ─── Allow all origins (public API, no credentials) ────────────────────────
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

Flask with flask-cors

# pip install flask flask-cors
from flask import Flask, jsonify
from flask_cors import CORS

app = Flask(__name__)

# ─── Apply CORS to all routes ───────────────────────────────────────────────
CORS(app,
     origins=["https://app.example.com", "http://localhost:3000"],
     supports_credentials=True,
     allow_headers=["Content-Type", "Authorization"],
     methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
     max_age=86400)

# ─── Per-resource CORS (more granular control) ───────────────────────────────
CORS(app, resources={
    r"/api/*": {
        "origins": ["https://app.example.com"],
        "supports_credentials": True,
    },
    r"/public/*": {
        "origins": "*",
    }
})

@app.route("/api/users", methods=["GET"])
def get_users():
    return jsonify({"users": []})

# ─── Decorator approach (route-level) ───────────────────────────────────────
from flask_cors import cross_origin

@app.route("/public/data")
@cross_origin()
def public_data():
    return jsonify({"data": "accessible to everyone"})

Credentials and Cookies — The Most Misunderstood CORS Requirement

Sending cookies or HTTP authentication headers with a cross-origin request requires a specific and strict configuration on both the client and the server. Getting either side wrong causes a CORS error.

Client-Side: Enabling Credentials in Fetch and Axios

// ─── fetch API ──────────────────────────────────────────────────────────────
const response = await fetch('https://api.example.com/profile', {
  method: 'GET',
  credentials: 'include',    // send cookies cross-origin
  headers: {
    'Content-Type': 'application/json',
    // Session cookies are sent automatically; don't put them in headers
  },
});

// credentials options:
// 'omit'    - never send cookies (default for cross-origin)
// 'same-origin' - only send for same-origin requests
// 'include' - always send cookies (requires server CORS credentials support)

// ─── XMLHttpRequest ──────────────────────────────────────────────────────────
const xhr = new XMLHttpRequest();
xhr.withCredentials = true;   // same as credentials: 'include' in fetch
xhr.open('GET', 'https://api.example.com/profile');
xhr.send();

// ─── axios ───────────────────────────────────────────────────────────────────
import axios from 'axios';

// Per-request:
const response = await axios.get('https://api.example.com/profile', {
  withCredentials: true,
});

// Global default (applies to all requests):
axios.defaults.withCredentials = true;

// Or with axios instance:
const api = axios.create({
  baseURL: 'https://api.example.com',
  withCredentials: true,
});

SameSite Cookie Interaction with CORS

Even if CORS is configured correctly, cookies may not be sent if the SameSite attribute prevents cross-site transmission. The three values behave as follows:

  • SameSite=Strict: Cookie is never sent in cross-site requests, regardless of CORS settings.
  • SameSite=Lax (modern browser default): Cookie is sent for top-level navigations but not for cross-site fetch or XMLHttpRequest.
  • SameSite=None; Secure: Cookie is sent in all cross-site requests. This is the value you need for cross-origin CORS with credentials. Requires Secure flag (HTTPS).
# Server: Set-Cookie for cross-origin use
Set-Cookie: session=abc123;
  SameSite=None;
  Secure;
  HttpOnly;
  Path=/

# Express (using cookie-session or express-session):
import session from 'express-session';

app.use(session({
  secret: process.env.SESSION_SECRET!,
  cookie: {
    sameSite: 'none',        // allow cross-site
    secure: true,            // required when sameSite='none'
    httpOnly: true,
    maxAge: 24 * 60 * 60 * 1000,  // 24h
  },
}));

CORS in Development — Webpack, Vite, and CRA Proxy

In local development, it is often easier to proxy API requests through the dev server than to configure CORS on the backend. The proxy makes the request from the same origin as the frontend, so CORS never applies.

Vite Proxy

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    proxy: {
      // Proxy /api/* to http://localhost:8080
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,   // changes Origin header to match target
        // rewrite: (path) => path.replace(/^/api/, ''),  // strip /api prefix
      },
      // Proxy WebSocket connections
      '/ws': {
        target: 'ws://localhost:8080',
        ws: true,
      },
    },
  },
});

// In your code, use relative paths:
const response = await fetch('/api/users');
// Browser sends to http://localhost:5173/api/users
// Vite proxies to http://localhost:8080/api/users (no CORS)

webpack-dev-server Proxy

// webpack.config.js
module.exports = {
  devServer: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        secure: false,   // accept self-signed SSL in dev
        logLevel: 'debug',
      },
    },
  },
};

Create React App Proxy

// package.json — simple proxy for all requests
{
  "proxy": "http://localhost:8080"
}

// For more control, use http-proxy-middleware:
// npm install http-proxy-middleware
// src/setupProxy.js
const { createProxyMiddleware } = require('http-proxy-middleware');

module.exports = function(app) {
  app.use(
    '/api',
    createProxyMiddleware({
      target: 'http://localhost:8080',
      changeOrigin: true,
    })
  );
};

This is why your app “works in development but fails in production.” The dev proxy removes CORS from the equation entirely. In production, you need actual CORS headers on the API server, or you need to serve the frontend and API from the same origin (recommended approach).

CORS Debugging Workflow — Browser DevTools and curl

Step 1: Read the Browser Console Error

Open Chrome/Firefox DevTools, go to the Console tab, and read the exact error message. The error tells you precisely what is missing. Key phrases to look for:

  • “No 'Access-Control-Allow-Origin' header is present” — server sends no CORS headers at all
  • “has been blocked by CORS policy” + origin mismatch — allowed origin doesn't match requesting origin
  • “does not pass access control check” + preflight — OPTIONS request failed
  • “not allowed by Access-Control-Allow-Headers” — missing header in preflight response

Step 2: Inspect in the Network Tab

# In Chrome/Firefox Network tab:
# 1. Look for an OPTIONS request — if present, check its response headers
# 2. Look at the failed request — check both request and response headers
# 3. Filter by "XHR" or "Fetch" to find API calls

# What to check in the OPTIONS preflight response:
# ✓ Access-Control-Allow-Origin: (your origin)
# ✓ Access-Control-Allow-Methods: (includes your method)
# ✓ Access-Control-Allow-Headers: (includes Authorization or custom headers)
# ✓ Status 200 or 204

# What to check in the actual request response:
# ✓ Access-Control-Allow-Origin: (your origin or *)
# ✓ Access-Control-Allow-Credentials: true (if using cookies)

Step 3: Test Preflight with curl

# Test preflight response manually:
curl -X OPTIONS https://api.example.com/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization" \
  -v 2>&1 | grep -i "access-control"

# Expected output:
# < Access-Control-Allow-Origin: https://app.example.com
# < Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
# < Access-Control-Allow-Headers: Content-Type, Authorization
# < Access-Control-Allow-Credentials: true
# < Access-Control-Max-Age: 86400

# Test actual request:
curl -X GET https://api.example.com/users \
  -H "Origin: https://app.example.com" \
  -H "Authorization: Bearer mytoken" \
  -v 2>&1 | grep -i "access-control"

# Test with credentials (simulate cookies):
curl -X GET https://api.example.com/profile \
  -H "Origin: https://app.example.com" \
  -b "session=abc123" \
  -v

CORS Security Considerations — Pitfalls to Avoid

The Dangers of Access-Control-Allow-Origin: *

Returning Access-Control-Allow-Origin: * is safe only for truly public, read-only APIs where you explicitly do not care which websites access the data. Never use wildcard origin for:

  • APIs that accept Authorization headers or cookies
  • APIs that return user-specific data
  • APIs that perform state-changing operations (POST, PUT, DELETE)
  • Internal APIs that should only be accessed by known clients

Origin Reflection Attack

A dangerous anti-pattern is blindly reflecting the Origin header back in the response without validating it. Some incorrect implementations do this:

// VULNERABLE: Reflecting any origin without validation
app.use((req, res, next) => {
  const origin = req.headers.origin;
  res.setHeader('Access-Control-Allow-Origin', origin || '*');  // WRONG!
  res.setHeader('Access-Control-Allow-Credentials', 'true');
  next();
});

// This is equivalent to using * but bypasses the browser's * + credentials check!
// Any website can make authenticated requests to your API.

// CORRECT: Validate against an allowlist
const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://admin.example.com',
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');  // IMPORTANT: tell caches origin matters
  }
  next();
});

The Vary: Origin header is critical when dynamically reflecting origins. Without it, CDNs and reverse proxy caches might serve a response with the wrong Access-Control-Allow-Origin to subsequent requests from different origins.

The null Origin Attack

Never add "null" to your allowed origins list. The Origin: null header is sent by:

  • Local HTML files opened with file://
  • Sandboxed iframes (<iframe sandbox>)
  • Redirected cross-origin requests
  • Data URLs

Attackers can create sandboxed iframes to forge Origin: null and bypass your allowlist if you include "null". Treat null as an untrusted origin.

CORS vs CSRF

CORS and CSRF (Cross-Site Request Forgery) are related but separate concepts. CORS controls whether JavaScript can read cross-origin responses. CSRF involves unauthorized state-changing requests that the browser sends automatically (with cookies) from a malicious page. Key points:

  • Proper CORS configuration with a strict origin allowlist reduces CSRF risk for JSON APIs.
  • CORS does not protect against CSRF for simple requests (no preflight) or browser form submissions.
  • Use CSRF tokens or the SameSite cookie attribute alongside CORS for complete protection.
  • Custom headers like X-API-Key or Content-Type: application/json trigger preflights, which provide CSRF protection since browsers can't forge preflighted cross-site requests.

WebSocket CORS — Different Rules, Similar Risks

WebSocket connections are initiated via an HTTP Upgrade request, but browsers do not apply CORS to WebSocket handshakes. This means WebSocket servers must implement their own origin validation to prevent cross-site WebSocket hijacking.

// Socket.IO server CORS configuration
import { createServer } from 'http';
import { Server } from 'socket.io';
import express from 'express';

const app = express();
const server = createServer(app);

const io = new Server(server, {
  cors: {
    origin: ['https://app.example.com', 'http://localhost:3000'],
    methods: ['GET', 'POST'],
    credentials: true,
  },
});

io.on('connection', (socket) => {
  console.log('Client connected:', socket.id);
  socket.on('message', (data) => {
    socket.emit('reply', { received: data });
  });
});

// Native WebSocket server — validate origin manually
import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });

const ALLOWED_WS_ORIGINS = ['https://app.example.com', 'http://localhost:3000'];

wss.on('connection', (ws, req) => {
  const origin = req.headers.origin;

  // Close connection if origin is not allowed
  if (origin && !ALLOWED_WS_ORIGINS.includes(origin)) {
    ws.close(1008, 'Origin not allowed');
    return;
  }

  ws.on('message', (message) => {
    console.log('Received:', message.toString());
    ws.send('Echo: ' + message);
  });
});

Where CORS Does NOT Apply — Mobile Apps, Electron, Postman

CORS is exclusively a browser security mechanism. Many environments that make HTTP requests are not subject to CORS enforcement:

EnvironmentCORS Enforced?Explanation
Chrome, Firefox, Safari, EdgeYesCORS is enforced by the browser engine
Postman, Insomnia, BrunoNoNot a browser — no SOP/CORS enforcement
curl, wget, httpieNoCommand-line tools, no browser context
Node.js, Python requests, Go httpNoServer-side HTTP — no same-origin policy
Electron appsNo (by default)Runs in main process (Node.js), not browser renderer
React Native, Flutter mobile appsNoNative HTTP client, no browser SOP
WebView in mobile appsDependsEmbedded browser engine may enforce CORS

This explains the common developer confusion: “My request works in Postman but fails in the browser.” The server sees the same request in both cases and returns the same response — it is the browser that intercepts and blocks the response when CORS headers are missing.

Common CORS Mistakes and Their Fixes

// ❌ MISTAKE 1: Adding CORS headers in the frontend
// (CORS headers must be set by the SERVER, not the client)
fetch('/api/data', {
  headers: {
    'Access-Control-Allow-Origin': '*',  // This does NOTHING
  }
});

// ❌ MISTAKE 2: Using wildcard with credentials
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Credentials', 'true');
// Browser will reject this combination!

// ✅ FIX 2:
res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com');
res.setHeader('Access-Control-Allow-Credentials', 'true');

// ❌ MISTAKE 3: Not handling OPTIONS preflight
app.post('/api/data', handler);  // OPTIONS to /api/data will 404 or 405

// ✅ FIX 3: Handle OPTIONS (cors npm package does this automatically)
app.options('/api/data', cors());
app.post('/api/data', cors(), handler);

// ❌ MISTAKE 4: Missing Vary header with dynamic origins
if (allowedOrigins.has(origin)) {
  res.setHeader('Access-Control-Allow-Origin', origin);
  // Without Vary: Origin, CDN may cache and return wrong origin
}

// ✅ FIX 4: Add Vary: Origin header
res.setHeader('Vary', 'Origin');

// ❌ MISTAKE 5: Trailing slash mismatch
// Allowed origin: 'https://app.example.com'
// Request origin: 'https://app.example.com/'  (with trailing slash)
// These are NOT equal — be consistent with or without trailing slash

// ❌ MISTAKE 6: Setting CORS headers inside error handlers only
// CORS headers must be present even on error responses

// ✅ FIX 6: Use always modifier in Nginx, or set headers before sending errors
// In Express:
app.use(cors(corsOptions));
app.use((err, req, res, next) => {
  // CORS headers already set by middleware above
  res.status(500).json({ error: err.message });
});

Key Takeaways

  • CORS is server-side: CORS headers must be set by the server. Client-side changes cannot fix CORS errors.
  • Origin = scheme + host + port: All three must match for same-origin. Subdomains are different origins.
  • Use cors npm package for Express — it handles OPTIONS preflight automatically and covers all edge cases.
  • Never use wildcard + credentials: Access-Control-Allow-Origin: * cannot be combined with Access-Control-Allow-Credentials: true.
  • Validate origins against an allowlist, never blindly reflect the Origin header without checking.
  • Set Vary: Origin when dynamically setting the origin to prevent CDN caching issues.
  • Set Access-Control-Max-Age to a high value (86400) to cache preflights and avoid performance penalties.
  • For cookies, set SameSite=None; Secure on the cookie in addition to CORS credentials support.
  • CORS does not apply to Postman, curl, Node.js, mobile apps — only browsers enforce CORS.
  • Dev proxy is the easiest dev solution: Vite proxy or webpack-dev-server proxy eliminates CORS in development.
𝕏 Twitterin LinkedIn
도움이 되었나요?

최신 소식 받기

주간 개발 팁과 새 도구 알림을 받으세요.

스팸 없음. 언제든 구독 해지 가능.

Try These Related Tools

🔓CORS Tester4xxHTTP Status Code Reference{ }JSON Formatter%20URL Encoder/Decoder

Related Articles

REST API 모범 사례: 2026년 완전 가이드

REST API 설계 모범 사례: 네이밍 규칙, 에러 처리, 인증, 페이지네이션, 보안을 배웁니다.

HTTP 상태 코드: 개발자를 위한 완전 참조 가이드

완전한 HTTP 상태 코드 참조: 1xx~5xx 실용적 설명, API 모범 사례, 디버깅 팁.

API 인증: OAuth 2.0 vs JWT vs API Key

API 인증 방법 비교: OAuth 2.0, JWT Bearer 토큰, API Key. 각 방법의 사용 시나리오, 보안 절충안, 구현 패턴.

Content Security Policy (CSP) 완전 가이드: 기초부터 프로덕션까지

CSP를 처음부터 배우기: 모든 디렉티브, 일반적인 설정, 리포팅, 배포.