How HTTP Works

Response header

< Access-Control-Allow-Origin: https://app.example.com

Access-Control-Allow-Origin Header: CORS Errors and Fixes

Access-Control-Allow-Origin explained: exact browser errors, why * fails with credentials, why you need Vary: Origin, and fixes for nginx, Express and Django.

Reviewed 4 min readintermediate4 sourcesTry itMarkdown
Direction
Response
Category
CORS
Spec
Fetch Standard
On this page

TL;DR: Access-Control-Allow-Origin names the one origin (or *) allowed to read a cross-origin response. Allow several origins by echoing the request’s Origin from an allowlist and adding Vary: Origin; * never works with cookies or Authorization.

What the browser checks

A page at https://app.example.com calls https://api.example.com/data. The browser adds Origin, sends the request, and only exposes the response to JavaScript if the reply allows that origin:

GET /data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
Content-Type: application/json

Valid values are one serialized origin (scheme, host, port, no path, no trailing slash), *, or null. Do not allow null: sandboxed iframes and file:// pages send it.

Exact error messages

Chrome:

Access to fetch at 'https://api.example.com/data' from origin 'https://app.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
The 'Access-Control-Allow-Origin' header has a value 'https://other.example.com' that is not equal to the supplied origin.
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'.

Firefox:

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://api.example.com/data. (Reason: CORS header 'Access-Control-Allow-Origin' missing). Status code: 200.
(Reason: Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*').
(Reason: Multiple CORS header 'Access-Control-Allow-Origin' not allowed).

The last one means two layers (for example the app and nginx) both added the header; remove one.

Diagnose

  1. Check the actual response, not the browser summary. Replay with the same Origin:
curl -si https://api.example.com/data -H 'Origin: https://app.example.com' | grep -iE '^(HTTP|access-control|vary)'
  1. If the request has a custom header, JSON Content-Type, or a method other than GET, HEAD or POST, the browser sends a preflight first. Test that too; it must return 2xx with the headers:
curl -si -X OPTIONS https://api.example.com/data \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type,authorization' | head -20
  1. Find who answers. A Server: cloudflare plus CF-Ray, or a Via or X-Amz-Cf-Id header, means a CDN or gateway may be answering, caching or stripping. An error page from the proxy (502, 413, 429) never carries your CORS headers, so the console shows a CORS error that is really a 5xx.
  2. Confirm the error responses also carry the header. Many apps add it only in the success path.

See the CORS debug page and preflight page.

Fix by stack

nginx. always is needed or the header is dropped on 4xx and 5xx responses. Handle OPTIONS yourself, and use a map for the allowlist:

map $http_origin $cors_origin {
    default "";
    "https://app.example.com"   $http_origin;
    "https://admin.example.com" $http_origin;
}

server {
    location /api/ {
        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Vary Origin always;
        add_header Access-Control-Allow-Credentials true always;

        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-Max-Age 600 always;
            add_header Vary Origin always;
            return 204;
        }
        proxy_pass http://app;
    }
}

add_header in a block replaces the parent’s, which is why the preflight block repeats them. If the upstream also sets the header you get the duplicate error above; use proxy_hide_header Access-Control-Allow-Origin; first.

Cloudflare Workers. Add headers to the response you return, and answer OPTIONS before fetching origin.

AWS. API Gateway REST APIs need CORS headers on both the OPTIONS mock integration and the Lambda response (proxy integration does not add them). S3 buckets use a CORS configuration with AllowedOrigins. CloudFront must forward Origin to the origin (an origin request policy such as the managed CORS-S3Origin) and the cache policy must include Origin in the cache key, or a response cached for one origin is served to another.

Express, Next.js, Django examples are in the code samples above.

Vary: Origin

If you return a different Access-Control-Allow-Origin per request, add Vary: Origin. Without it, a shared cache or the browser’s own cache can store the response generated for origin A and serve it to origin B, whose browser then fails with “is not equal to the supplied origin”. It also explains CORS errors that appear only after a refresh or on the second visit. See Vary.

Credentials and wildcards

With credentials: 'include', cookies and Authorization are only sent and honoured if the response has an exact origin plus Access-Control-Allow-Credentials: true. Wildcards are treated literally in Access-Control-Allow-Headers, Access-Control-Allow-Methods and Access-Control-Expose-Headers for credentialed requests. Never reflect an arbitrary Origin with credentials enabled; that lets any site act as the logged-in user. Cookie-based credentials additionally depend on SameSite being None; Secure for cross-site calls.

In practice

Express.js
const cors = require('cors')

const ALLOWED = new Set(['https://app.example.com', 'https://admin.example.com'])

app.use(
  cors({
    origin(origin, cb) {
      // No Origin header = same-origin or non-browser client
      if (!origin || ALLOWED.has(origin)) return cb(null, true)
      cb(null, false) // omit CORS headers rather than throwing a 500
    },
    credentials: true
  })
)
Next.js App Router
// app/api/data/route.ts
const ALLOWED = new Set(['https://app.example.com'])

function corsHeaders(origin: string | null): HeadersInit {
  const h: Record<string, string> = { Vary: 'Origin' }
  if (origin && ALLOWED.has(origin)) {
    h['Access-Control-Allow-Origin'] = origin
  }
  return h
}

export async function GET(request: Request) {
  return Response.json({ data: [] }, { headers: corsHeaders(request.headers.get('origin')) })
}

export async function OPTIONS(request: Request) {
  return new Response(null, {
    status: 204,
    headers: {
      ...corsHeaders(request.headers.get('origin')),
      'Access-Control-Allow-Methods': 'GET, POST',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      'Access-Control-Max-Age': '600'
    }
  })
}
Django
# pip install django-cors-headers
# settings.py
INSTALLED_APPS = [..., 'corsheaders']
MIDDLEWARE = ['corsheaders.middleware.CorsMiddleware', ...]  # before CommonMiddleware

CORS_ALLOWED_ORIGINS = [
    'https://app.example.com',
    'https://admin.example.com',
]
CORS_ALLOW_CREDENTIALS = True

Frequently asked questions

What does Access-Control-Allow-Origin do?

It tells the browser which origin may read a cross-origin response. The server either returns one specific origin, the literal null, or *. If the value does not match the requesting page origin, the browser hides the response from JavaScript and logs a CORS error. The request itself still reached the server.

Can I allow multiple origins in Access-Control-Allow-Origin?

No. The header takes a single origin or *. To support several origins, compare the request Origin against an allowlist, echo back the matching one, and always send Vary: Origin so caches do not serve one origin the response meant for another.

Why does Access-Control-Allow-Origin: * not work with cookies or Authorization?

For credentialed requests (fetch credentials: include, or XHR withCredentials) the Fetch Standard forbids the wildcard. The response must name the exact origin and include Access-Control-Allow-Credentials: true. The same applies to * in Allow-Headers and Allow-Methods.

Why do I still see a CORS error when the header is present?

Usual causes: the preflight OPTIONS response lacks the header or returns a non-2xx status, the header is added only on 200 responses and not on errors such as 401, 404 or 500, a proxy or CDN stripped or cached it, or the value has a trailing slash or wrong scheme or port.

Is Access-Control-Allow-Origin: * a security risk?

Not for public, unauthenticated data. It becomes a problem when an API relies on network position (intranet, localhost) because any website a user visits could read it. Reflecting any Origin back together with Allow-Credentials: true is the genuinely dangerous pattern.

Does CORS protect my server from other websites?

No. CORS is enforced by browsers to protect users. curl, servers and attackers sending requests directly ignore it, and simple cross-origin requests are still delivered to your server. Authenticate and authorize on the server regardless.

Sources

  1. MDN Web Docs: Access-Control-Allow-Origindeveloper.mozilla.org
  2. Fetch Standard: CORS protocolfetch.spec.whatwg.org
  3. MDN: CORS errorsdeveloper.mozilla.org
  4. nginx: ngx_http_headers_module add_headernginx.org

Keep going

Browse /search