How to check CORS configuration

Read every access-control-* header the API returns for a real origin: curl -s https://api.example.com/orders -H 'Origin: https://app.example.com' -D - -o /dev/null. A configured API returns your exact origin, a vary: origin line, and no CORS header at all for an origin outside its list.

Checker offline. Follow the manual steps below, they give the same answer.

Why check this

An audit of the CORS configuration belongs on staging sign-off, after any change to the API gateway, and before a new front end host goes live. One command tells you whether the API holds a list of origins or copies back whatever arrives. A service that copies the origin and also sends access-control-allow-credentials: true lets any page the tester's browser opens read that tester's logged-in responses, and no status code in the test report shows it.

Prerequisites

const http = require('node:http');
const allowed = new Set(['https://app.example.com', 'http://localhost:3000']);
http.createServer((req, res) => {
  const origin = req.headers.origin;
  res.setHeader('Vary', 'Origin');
  if (origin && allowed.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Access-Control-Expose-Headers', 'X-Request-Id, X-RateLimit-Remaining');
  }
  res.setHeader('X-Request-Id', 'req-42');
  res.setHeader('X-RateLimit-Remaining', '59');
  res.setHeader('Content-Type', 'application/json');
  res.writeHead(200);
  res.end('{"ok":true}');
}).listen(8099, () => console.log('listening on 8099'));

Steps

  1. Step 1.

    Read the CORS headers the API returns for an origin that belongs to you.

    curl -s 'https://httpbin.org/get' -H 'Origin: https://app.example.com' -D - -o /dev/null | grep -i -E 'access-control|vary|^HTTP'
    
    HTTP/2 200
    access-control-allow-origin: https://app.example.com
    access-control-allow-credentials: true

    Three facts are already visible: the origin is allowed, cookies are allowed with it, and there is no vary line naming Origin.

  2. Step 2.

    Repeat the request with an origin nobody in the project owns.

    curl -s 'https://httpbin.org/get' -H 'Origin: https://evil.example' -D - -o /dev/null | grep -i -E 'access-control|vary|^HTTP'
    
    HTTP/2 200
    access-control-allow-origin: https://evil.example
    access-control-allow-credentials: true

    The header follows the request, so there is no allow list here. Reflection plus credentials is the finding to file.

  3. Step 3.

    Find out which response headers the browser hands to JavaScript.

    curl -s 'https://api.github.com/repos/curl/curl' -H 'Origin: https://h2check.org' -D - -o /dev/null | grep -i -E 'access-control-expose-headers|access-control-allow-origin|^vary'
    
    vary: Accept,Accept-Encoding, Accept, X-Requested-With
    access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, …
    access-control-allow-origin: *

    Without that list, client code reads only the seven safelisted headers: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma.

  4. Step 4.

    Start cors-allowlist.js with node cors-allowlist.js, then probe it with an origin on its list.

    curl -s 'http://localhost:8099/orders' -H 'Origin: https://app.example.com' -D - -o /dev/null
    
    HTTP/1.1 200 OK
    Vary: Origin
    Access-Control-Allow-Origin: https://app.example.com
    Access-Control-Allow-Credentials: true
    Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining
    X-Request-Id: req-42

    This is the shape to compare your API against: one exact origin, Vary: Origin, credentials, and a named list of readable headers.

  5. Step 5.

    Send the same request from an origin that is not on the list, then stop the server.

    curl -s 'http://localhost:8099/orders' -H 'Origin: https://evil.example' -D - -o /dev/null
    
    HTTP/1.1 200 OK
    Vary: Origin
    X-Request-Id: req-42
    X-RateLimit-Remaining: 59
    Content-Type: application/json

    The body still arrives over curl and the CORS headers are gone, which is what makes the browser discard it.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | access-control-allow-origin holds your exact origin | The origin is on a list | Nothing. Record the list in the test notes. | | The header changes to match every origin you send | The server reflects instead of matching | Ask for a list. With credentials on, raise it as a security defect. | | access-control-allow-origin: * with access-control-allow-credentials: true | An invalid pair | The browser drops the response. Pick one of the two. | | A per-origin header with no vary: origin | A CDN can serve one origin's header to another | Add Vary: Origin before the response is cached. | | No access-control-expose-headers | JavaScript sees only the seven safelisted headers | Name the headers the client reads, such as a request id or a quota counter. | | access-control-max-age missing or tiny | Every call repeats the preflight | See How to test preflight request. |

Common mistakes

Sign: Every origin the tester tries is accepted, so the configuration is marked as working.Cause: The server copies the Origin header into the response instead of matching it against a list. Probing with origins you own can never fail. Step 2 uses an origin nobody owns, which is the only probe that separates a list from a mirror.
Sign: The API answers correctly from curl and the page breaks for some users only.Cause: The response carries a per-origin allow-origin header and no Vary: Origin, so a CDN or a corporate proxy stores the copy made for the first origin and replays it to the next one. The failure follows cache nodes, not users.
Sign: The client reads a header in Postman and gets null for the same header in the browser.Cause: The header is present on the wire but missing from access-control-expose-headers, so the browser hides it from JavaScript. Pagination links and rate limit counters are the usual casualties.
Sign: One route is configured and the next route returns nothing.Cause: CORS is applied by a gateway rule or a per-route middleware, not by the service as a whole. Audit each route the front end calls, including the error routes, because a 500 handler often skips the CORS layer.

What to check next

FAQ

How to check if CORS is enabled?

Send any request with an Origin header and count the access-control-allow-origin lines in the response. Zero means the route has no CORS layer. The status code stays 200 either way, so the header is the only signal.

How to check which origins an API allows?

You cannot list them from outside. Test the origins you care about one at a time, then add one origin nobody owns. If that one is accepted too, the API has no list and the answer is every origin.

How to check CORS policy in the browser?

Open DevTools, Network tab, select the failing request, then the Headers panel. Compare the Origin you sent with access-control-allow-origin. The Console tab names the rule that failed, such as credentials with a wildcard.

Can access-control-allow-origin hold several origins?

No. The header carries one origin or a star. Servers that need several keep a list, match the incoming Origin against it, and echo the single matching value, which is what step 4 shows.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate6 minpublished updated Maks Verny