# 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.

Source: https://howhttpworks.com/headers/access-control-allow-origin
Last reviewed: 2026-10-04

> **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:

```http
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:

```text
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.
```

```text
The 'Access-Control-Allow-Origin' header has a value 'https://other.example.com' that is not equal to the supplied origin.
```

```text
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:

```text
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.
```

```text
(Reason: Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*').
```

```text
(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`:

```bash
curl -si https://api.example.com/data -H 'Origin: https://app.example.com' | grep -iE '^(HTTP|access-control|vary)'
```

2. 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:

```bash
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
```

3. 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.
4. Confirm the error responses also carry the header. Many apps add it only in the success path.

See the [CORS debug page](https://howhttpworks.com/debug/cors-error) and [preflight page](https://howhttpworks.com/debug/cors-preflight).

## 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:

```nginx
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](https://howhttpworks.com/headers/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](https://howhttpworks.com/cookies/same-site) being `None; Secure` for cross-site calls.

## Related

- [Access-Control-Allow-Credentials](https://howhttpworks.com/headers/access-control-allow-credentials), [Allow-Methods](https://howhttpworks.com/headers/access-control-allow-methods), [Allow-Headers](https://howhttpworks.com/headers/access-control-allow-headers), [Max-Age](https://howhttpworks.com/headers/access-control-max-age), [Origin](https://howhttpworks.com/headers/origin)
- [CORS guide](https://howhttpworks.com/guides/cors), [CORS vs CSP](https://howhttpworks.com/compare/cors-vs-csp) and the [CORS debugger](https://howhttpworks.com/tools/cors-debugger)
