// Engineering Log

HTTPS and Web Server in Practice: Part 3 — What Is CORS: Browser Console Errors and Configuration in Nginx

Published on 2026-11-02 · Author:

// Fast route

This article belongs to the topic Servers and infrastructure.

CORS (Cross-Origin Resource Sharing) — a mechanism by which a server allows JavaScript code from other sites to read its responses. By default the browser does not allow this: a script on https://app.example.ru can send a request to https://api.example.ru, but will not be able to read the response unless the API server explicitly allowed it. Hence errors like “blocked by CORS policy” in the console, which appear as soon as the frontend and API are on different addresses. Below — how the check works, what each typical error means, and how to configure the server. The HTTP headers themselves are briefly covered in the article “HTTP headers: what they are and what they do”.

What is an “origin”

An origin is the scheme, host, and port together. Two addresses belong to the same origin only if all three parts match:

Page URLRequest URLSame origin?
https://example.ruhttps://example.ru/apiyes
https://example.ruhttps://api.example.runo — different host
https://example.ruhttp://example.runo — different scheme
http://localhost:3000http://localhost:8000no — different port

A subdomain is already a different origin. Local development with the frontend on port 3000 and the API on 8000 is also cross-origin.

What the browser blocks exactly

The same-origin policy forbids a script from reading responses from other origins. It usually does not forbid sending requests. CORS is a way to relax this rule for specific origins.

Three important implications:

  • CORS protects the user, not the server. curl, Postman, a Python script, or another server do not check CORS and will always receive the response. You can restrict API access only by authentication;
  • a “simple” request from another site reaches the server and is executed — the browser simply does not give the response to the script. Protection against forging such requests is not CORS but CSRF tokens and cookie SameSite (“Attacks at the HTTP level”);
  • a CORS error is a server response missing the required headers, not a network failure. The server may have returned 200 or 500: the real status code is visible on the Network tab.

Simple requests and preflight request

The browser sends a simple request directly and checks the response headers. A request is considered simple if all of the following are true:

  • method is GET, HEAD, or POST;
  • headers are only from a short allowed list (Accept, Accept-Language, Content-Language, Content-Type, and some others), without Authorization and custom X-… headers;
  • Content-Type is only application/x-www-form-urlencoded, multipart/form-data, or text/plain.

Any other request — PUT, PATCH, DELETE, JSON with Content-Type: application/json, a request with Authorization — requires a preflight. The browser first sends an OPTIONS:

OPTIONS /v1/orders HTTP/1.1
Host: api.example.ru
Origin: https://app.example.ru
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type

The server must respond with a 2xx status and permissions:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.ru
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 7200
Vary: Origin

Only after this the real PUT is sent, and its response must also contain Access-Control-Allow-Origin. Access-Control-Max-Age allows the browser to cache the preflight result; Chrome limits this to two hours.

Response headers

  • Access-Control-Allow-Origin — which origin can read the response: a specific one (https://app.example.ru) or any (*). The value is singular: a comma-separated list is not allowed;
  • Access-Control-Allow-Methods, Access-Control-Allow-Headers — allowed methods and headers, only in response to OPTIONS;
  • Access-Control-Allow-Credentials: true — allow requests with cookies and Authorization;
  • Access-Control-Expose-Headers — which response headers are available to the script. By default the script sees only a few basic ones (Content-Type, Content-Length, Cache-Control, etc.); to read X-Total-Count or Content-Disposition they must be listed;
  • Access-Control-Max-Age — caching the preflight request, in seconds;
  • Vary: Origin — required if Allow-Origin depends on the request Origin. Otherwise a CDN or browser may cache a response with permission for one site and give it to another.

Requests with cookies and authorization

By default fetch does not send cookies to another origin. To send them:

javascript
fetch("https://api.example.ru/v1/me", { credentials: "include" });

Then the server must:

  • return a specific origin in Access-Control-Allow-Origin — * is forbidden together with credentials;
  • return Access-Control-Allow-Credentials: true;
  • have a proper SameSite on the cookie: for a different site — SameSite=None; Secure. For subdomains of the same site (app.example.ru and api.example.ru — one “site” in terms of SameSite) Lax is sufficient.

A token in the Authorization header is simpler than a cookie: no credentials: "include" needed, but Authorization must be included in Access-Control-Allow-Headers.

Console errors and what they mean

No 'Access-Control-Allow-Origin' header is present on the requested resource The server did not return the header. Reasons: CORS is not configured; it is configured but the response came with an error (404, 500, 502), and headers are added only to successful responses — in Nginx always is missing; the request went to the wrong address. Check the real response code on the Network tab.

Response to preflight request doesn't pass access control check: It does not have HTTP ok status The response to OPTIONS is not 2xx. Most often OPTIONS requires authentication (401), is not supported by the framework (404, 405), or is redirected. The preflight always goes without cookies and Authorization — the server must answer it without access checks.

Redirect is not allowed for a preflight request A redirect was returned for OPTIONS: from http to https, from a path with a slash to without (/v1/orders → /v1/orders/), or to the login page. Fix the frontend address so there is no redirect.

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' The request includes cookies, but the response has *. A specific origin and Allow-Credentials: true are required.

Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response The header is not listed in Access-Control-Allow-Headers. Same for content-type when sending JSON and for any custom headers.

Method PUT is not allowed by Access-Control-Allow-Methods in preflight response The method is not listed in Access-Control-Allow-Methods.

The 'Access-Control-Allow-Origin' header contains multiple values '…, …', but only one is allowed The header is added by two layers at once — for example, both Nginx and the application, or both the application and a CDN. Keep CORS in one place.

The response arrives, but response.headers.get("X-Total-Count") returns null The header is not listed in Access-Control-Expose-Headers.

Configuration in Nginx

Allowed origins — by list using map, so you don’t echo any Origin:

nginx
map $http_origin $cors_origin {
    default "";
    "https://app.example.ru"   $http_origin;
    "https://admin.example.ru" $http_origin;
    "~^http://localhost:\d+$"  $http_origin;   # only for the test environment
}

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

    location / {
        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Origin      $cors_origin always;
            add_header Access-Control-Allow-Credentials true always;
            add_header Access-Control-Allow-Methods     "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
            add_header Access-Control-Allow-Headers     "Authorization, Content-Type, X-Request-ID" always;
            add_header Access-Control-Max-Age           7200 always;
            add_header Vary                             Origin always;
            return 204;
        }

        add_header Access-Control-Allow-Origin      $cors_origin always;
        add_header Access-Control-Allow-Credentials true always;
        add_header Access-Control-Expose-Headers    "X-Total-Count, Content-Disposition" always;
        add_header Vary                             Origin always;

        proxy_pass http://127.0.0.1:8080;
    }
}

Notes:

  • if with return inside location is one of the acceptable uses of if in Nginx;
  • if the origin is not in the list, $cors_origin is empty, and Nginx does not emit a header with an empty value — the browser will block reading the response;
  • always is needed so headers are present in 4xx/5xx responses as well — otherwise the frontend will see a CORS error instead of a clear API error;
  • if another add_header appears in the location, headers defined at the server level will stop working there — keep all headers in one place.

Configuration in the application

If CORS is configured in the application, Nginx should not add headers at all:

  • Express: the cors package — app.use(cors({ origin: ["https://app.example.ru"], credentials: true }));
  • Django: django-cors-headers — CORS_ALLOWED_ORIGINS = ["https://app.example.ru"], CORS_ALLOW_CREDENTIALS = True;
  • FastAPI: CORSMiddleware with allow_origins, allow_credentials, allow_methods, allow_headers;
  • Spring: @CrossOrigin or a global CorsConfigurationSource.

The CORS middleware in the application must run before authentication, otherwise OPTIONS will get a 401.

How to avoid CORS

The simplest solution is to avoid cross-domain requests:

  • one domain for frontend and API: https://example.ru/ — the site, https://example.ru/api/ — the API served through the same Nginx. The origin is the same, CORS is not needed, cookies work without SameSite=None;
  • proxy in development: in Vite — server.proxy, in Create React App — the proxy field in package.json. The frontend calls /api on its own port, and the dev server forwards requests to the API.

How to test

You can reproduce the preflight with curl:

bash
curl -si -X OPTIONS https://api.example.ru/v1/orders \
  -H 'Origin: https://app.example.ru' \
  -H 'Access-Control-Request-Method: PUT' \
  -H 'Access-Control-Request-Headers: authorization, content-type' \
  | grep -i '^access-control\|^HTTP\|^vary'

And the main request — with the same Origin:

bash
curl -si https://api.example.ru/v1/orders -H 'Origin: https://app.example.ru' | grep -i '^access-control'

There should be no Access-Control-Allow-Origin header for an origin not in the allowed list.

Common mistakes

  • Access-Control-Allow-Origin is taken from the request Origin without checking against a list — this effectively allows any site, including with cookies.
  • Access-Control-Allow-Origin: * on an API that uses cookie-based authentication — does not work and indicates CORS was configured blindly.
  • No Vary: Origin when the response depends on Origin — a CDN serves a response with someone else’s permission.
  • CORS configured both in Nginx and the application — duplicate header.
  • OPTIONS protected by authentication or rate limiting — the entire frontend breaks under any load.
  • Attempting to “fix CORS” on the frontend (mode: "no-cors") — the request is sent, but the response becomes completely unavailable to the script.

// Similar task

If you are dealing with something similar

This article belongs to one of the main working topics. You can keep reading on the topic, go to the homepage to understand what I do, or open the service pages directly.

Article topic

Servers and infrastructure

VPS, Linux, web stack, migrations, hosting, databases, and core operations.

Typical tasks behind this topic

  • Move a site or service to a new server
  • Set up Linux, Nginx, databases, and backups
  • Figure out why the system behaves unstably

// Next step

If you need help with this topic, not just another article, it is better to go straight to the service page. The homepage and topic collection stay available as secondary routes.

Open services

// Reviews

Related reviews

apande

apande

Configuring Nginx and OpenCart

2024-09-07 · ★ 5/5

A very powerful buyer
kireevk

kireevk

Diagnosing Nginx Proxy Manager in a Docker container and resolving the issue

2024-03-15 · ★ 5/5

Settled-in customer

// Contact

Need help?

Get in touch with me and I'll help solve the problem

I reply within one business day (03:00-13:00 GMT)

Or leave a request here:

Confirm that you are not a bot.

Write and get a quick reply