Skip to main content
Technology & EngineeringSecurity Practices230 lines

CORS Security

Configure CORS headers correctly to control cross-origin resource access while preventing overly permissive policies.

Quick Summary18 lines
You are an expert in configuring Cross-Origin Resource Sharing (CORS) policies to enable legitimate cross-origin access while preventing unauthorized access to APIs and resources.

## Key Points

- `https://app.example.com` and `https://app.example.com/api` — same origin
- `https://app.example.com` and `http://app.example.com` — different (scheme)
- `https://app.example.com` and `https://api.example.com` — different (host)
- `https://app.example.com` and `https://app.example.com:8443` — different (port)
- **Simple requests**: GET, HEAD, POST with standard content types. The browser sends the request directly and checks CORS headers on the response.
1. **Never use `Access-Control-Allow-Origin: *` with credentials**: Browsers reject this combination, but reflecting any origin with credentials is equally dangerous.
2. **Maintain an explicit allowlist of origins**: Avoid reflecting the request Origin header without validation.
3. **Restrict methods and headers**: Only allow the HTTP methods and headers your API actually uses.
4. **Set `Max-Age` for preflight caching**: Reduces the number of OPTIONS requests. 86400 (24 hours) is a common value.
5. **Use per-route CORS where needed**: Public endpoints can be more permissive; authenticated endpoints should be strict.
6. **Do not rely on CORS for security**: CORS is a browser mechanism. Server-to-server requests, curl, and non-browser clients bypass it entirely. Always authenticate and authorize on the server.
7. **Handle the null origin carefully**: Sandboxed iframes and local file origins send `Origin: null`. Do not allowlist `null` as a string.
skilldb get security-practices-skills/CORS SecurityFull skill: 230 lines
Paste into your CLAUDE.md or agent config

CORS Configuration — Application Security

You are an expert in configuring Cross-Origin Resource Sharing (CORS) policies to enable legitimate cross-origin access while preventing unauthorized access to APIs and resources.

Core Philosophy

CORS is not a security mechanism you add to protect your application; it is a controlled relaxation of the browser's default Same-Origin Policy. The mindset should always be "deny by default, allow by exception." Every origin you add to the allowlist is an expansion of your attack surface, and each one should be justified by a concrete business requirement.

The most important insight about CORS is that it only governs browser behavior. Server-to-server requests, command-line tools, and mobile apps bypass CORS entirely. This means CORS can never replace authentication, authorization, or input validation. Its role is narrowly scoped: preventing a malicious website from using a victim's browser as a proxy to make authenticated requests to your API. When developers mistake CORS for a general-purpose access control mechanism, they either over-rely on it (leaving server-side checks weak) or over-configure it (adding complexity without security benefit).

A well-managed CORS configuration reflects the application's actual deployment topology. It knows exactly which frontend origins need access, which HTTP methods each endpoint supports, and whether credentials are required. It evolves with the architecture — when a frontend is decommissioned, its origin is removed; when a new service is added, its origin is explicitly approved. Treating CORS configuration as living infrastructure rather than a set-and-forget header prevents the gradual drift toward overly permissive policies.

Anti-Patterns

  • Reflecting the request Origin header without validation: Dynamically mirroring whatever origin the browser sends in the Access-Control-Allow-Origin response header effectively makes CORS open to every site on the internet, defeating its entire purpose.

  • Using Access-Control-Allow-Origin: * on authenticated endpoints: While the browser blocks this combination with credentials, developers sometimes work around it by reflecting the origin, which is equally dangerous and allows any site to make credentialed requests.

  • Configuring CORS once and never revisiting it: As frontends are added, removed, or migrated to new domains, stale origins accumulate in the allowlist. Unused allowed origins are dormant vulnerabilities waiting to be exploited if those domains are ever taken over.

  • Applying the same permissive CORS policy to every route: Public health-check endpoints and authenticated mutation endpoints have very different security requirements. Applying a single broad policy to the entire API unnecessarily widens the attack surface for sensitive routes.

  • Assuming CORS errors mean "add more permissions": When a developer encounters a CORS error, the correct response is to understand why the request is cross-origin and whether it should be. The incorrect response is to keep widening the policy until the error disappears.

Overview

CORS is a browser security mechanism that controls which origins can make requests to your server. By default, browsers block cross-origin requests (the Same-Origin Policy). CORS headers tell the browser which exceptions to allow. Misconfigured CORS can expose APIs to unauthorized origins, leak credentials, or enable data theft.

Core Concepts

Same-Origin Policy

Two URLs share the same origin if and only if they have the same scheme, host, and port:

  • https://app.example.com and https://app.example.com/api — same origin
  • https://app.example.com and http://app.example.com — different (scheme)
  • https://app.example.com and https://api.example.com — different (host)
  • https://app.example.com and https://app.example.com:8443 — different (port)

CORS Headers

HeaderPurpose
Access-Control-Allow-OriginWhich origins are allowed (* or a specific origin)
Access-Control-Allow-MethodsAllowed HTTP methods for preflight
Access-Control-Allow-HeadersAllowed request headers for preflight
Access-Control-Allow-CredentialsWhether cookies/auth headers are sent (true or omit)
Access-Control-Expose-HeadersWhich response headers the client can read
Access-Control-Max-AgeHow long the preflight result is cached (seconds)

Simple vs. Preflighted Requests

  • Simple requests: GET, HEAD, POST with standard content types. The browser sends the request directly and checks CORS headers on the response.
  • Preflighted requests: Requests with custom headers, non-standard methods (PUT, DELETE), or non-standard content types trigger an OPTIONS preflight. The browser asks permission before sending the actual request.

The Credential Rule

When Access-Control-Allow-Credentials: true is set, Access-Control-Allow-Origin must not be *. It must be a specific origin. This prevents credential leakage to arbitrary origins.

Implementation Patterns

Express with cors Middleware

const cors = require('cors');

// GOOD — explicit allowlist
const ALLOWED_ORIGINS = [
  'https://app.example.com',
  'https://admin.example.com',
];

app.use(cors({
  origin: (origin, callback) => {
    // Allow requests with no origin (server-to-server, mobile apps)
    if (!origin) return callback(null, true);

    if (ALLOWED_ORIGINS.includes(origin)) {
      return callback(null, true);
    }
    return callback(new Error('Not allowed by CORS'));
  },
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
  credentials: true,
  maxAge: 86400,   // cache preflight for 24 hours
}));

Environment-Aware CORS

function getCorsConfig() {
  if (process.env.NODE_ENV === 'development') {
    return {
      origin: ['http://localhost:3000', 'http://localhost:5173'],
      credentials: true,
    };
  }

  return {
    origin: ['https://app.example.com'],
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization'],
    maxAge: 86400,
  };
}

app.use(cors(getCorsConfig()));

FastAPI (Python)

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

ALLOWED_ORIGINS = [
    "https://app.example.com",
    "https://admin.example.com",
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=ALLOWED_ORIGINS,       # not ["*"] with credentials
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Content-Type", "Authorization"],
    max_age=86400,
)

Nginx CORS Headers

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

    # Map allowed origins
    set $cors_origin "";
    if ($http_origin ~* "^https://(app|admin)\.example\.com$") {
        set $cors_origin $http_origin;
    }

    location /api/ {
        # Handle preflight
        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;
            add_header 'Content-Length' 0;
            return 204;
        }

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

        proxy_pass http://backend;
    }
}

Per-Route CORS (Express)

const publicCors = cors({ origin: '*', methods: ['GET'] });
const authedCors = cors({
  origin: ['https://app.example.com'],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
});

// Public endpoints — open CORS, no credentials
app.get('/api/public/health', publicCors, healthHandler);
app.get('/api/public/docs', publicCors, docsHandler);

// Authenticated endpoints — restricted origin
app.use('/api/v1', authedCors, authMiddleware, apiRouter);

Dynamic Origin with Subdomain Pattern

app.use(cors({
  origin: (origin, callback) => {
    if (!origin) return callback(null, true);

    // Allow any subdomain of example.com over HTTPS
    const pattern = /^https:\/\/([a-z0-9-]+\.)?example\.com$/;
    if (pattern.test(origin)) {
      return callback(null, true);
    }

    callback(new Error('Origin not allowed'));
  },
  credentials: true,
}));

Best Practices

  1. Never use Access-Control-Allow-Origin: * with credentials: Browsers reject this combination, but reflecting any origin with credentials is equally dangerous.
  2. Maintain an explicit allowlist of origins: Avoid reflecting the request Origin header without validation.
  3. Restrict methods and headers: Only allow the HTTP methods and headers your API actually uses.
  4. Set Max-Age for preflight caching: Reduces the number of OPTIONS requests. 86400 (24 hours) is a common value.
  5. Use per-route CORS where needed: Public endpoints can be more permissive; authenticated endpoints should be strict.
  6. Do not rely on CORS for security: CORS is a browser mechanism. Server-to-server requests, curl, and non-browser clients bypass it entirely. Always authenticate and authorize on the server.
  7. Handle the null origin carefully: Sandboxed iframes and local file origins send Origin: null. Do not allowlist null as a string.
  8. Test CORS configuration: Use curl -H "Origin: https://evil.com" -I to verify headers are not reflected for unauthorized origins.

Common Pitfalls

  • Reflecting the Origin header without validation: res.setHeader('Access-Control-Allow-Origin', req.headers.origin) allows every origin. Always check against an allowlist.
  • Using * with credentials: true: This is invalid per the spec. Browsers will block the response.
  • Forgetting preflight for custom headers: If your frontend sends Authorization or X-Custom-Header, the browser issues a preflight. The server must respond to OPTIONS with the correct headers.
  • Allowing overly broad subdomain patterns: *.example.com may include user-controlled subdomains if you offer custom subdomains.
  • Not setting CORS headers on error responses: If your error handler does not include CORS headers, the browser cannot read the error message. Use always in Nginx or handle errors in the CORS middleware.
  • Assuming CORS replaces authentication: CORS does not authenticate or authorize. It is only a browser-level access control on which origins can read responses.

Install this skill directly: skilldb add security-practices-skills

Get CLI access →