Skip to main content
Blog

Web

CORS misconfiguration: reflected Origin, wildcards and null

CORS is not protection but a permission granted to the browser. Get it wrong and a foreign site reads your API on the user's behalf. Testing with curl and the correct configuration.

· 9 min read

Photo: Nenea hartia, CC BY-SA 4.0 · Wikimedia Commons

Developers usually think about CORS (Cross-Origin Resource Sharing) in one situation: the frontend calls the API and a red error appears in the browser console. As a quick fix, an "allow everything" setting found online goes in, and the error disappears. That setting then makes its way to production, and nobody ever comes back to it.

In this article we show what CORS actually does, which settings are dangerous, how to test them with curl, and how to configure CORS correctly in Laravel, Node and nginx.

What CORS is and what it is not

Browsers enforce a basic rule, the Same-Origin Policy: JavaScript on https://evil.example can send a request to https://api.your-domain.uz, but it cannot read the response. An origin is the combination of scheme, domain and port: https://app.your-domain.uz and https://api.your-domain.uz are different origins.

CORS is the mechanism for deliberately making exceptions to this rule. By adding headers to the response, the server tells the browser "let this origin read the response":

Access-Control-Allow-Origin: https://app.your-domain.uz
Access-Control-Allow-Credentials: true

Two important conclusions:

  • CORS is a permission layer, not a protection layer. It relaxes a restriction; it does not tighten one. Every wrong value is one more open door.
  • CORS only works in the browser. curl, servers and mobile apps ignore it. So CORS does not replace authorization: the API must check the user on every request by itself.

Why this matters for the expertise

The danger of a CORS mistake is that the user's own browser becomes the attack tool. While signed in to your system, the user opens a foreign page. If the API uses cookies and CORS trusts any origin, a script on that page sends a request as the user, reads the response — profile, reports, tokens — and ships it to its own server.

In the expertise this is rated as "Security Misconfiguration" or information disclosure. It is easy to find: a few curl requests are enough. That is why this item belongs on the list of settings you should definitely check before the expertise.

Dangerous case 1: reflected Origin + credentials

The most serious mistake. The server takes the Origin header from the request, echoes it back unchanged and allows cookies:

Access-Control-Allow-Origin: https://evil.example
Access-Control-Allow-Credentials: true

In practice this means "any website can read the API as the user". It is often the result of code like this:

// WRONG
app.use(cors({ origin: true, credentials: true }));

In the cors package, origin: true means "reflect whatever Origin came in".

Dangerous case 2: the wildcard

Access-Control-Allow-Origin: * means "any origin may read the response". Browsers do not accept * together with Allow-Credentials: true, so cookie-protected data cannot be read directly. But * can still be dangerous:

  • if the API returns resources from an internal network or IP-restricted resources without authentication;
  • if access is granted by some other "ambient" means instead of cookies (an IP allowlist, for example);
  • if a developer later replaces * with Origin reflection because "* doesn't work" — which leads straight to case 1.

For public, genuinely open data (exchange rates, say) * is acceptable. For a user account API it is not.

Dangerous case 3: the null origin

Some configurations have null in the allowlist — usually added for testing local files. But an attacker can send Origin: null too: requests from a sandboxed iframe, a data: URL or a file:// page carry exactly that value. Never allow null as an origin.

Dangerous case 4: broken checks (regex and endsWith)

If the code that checks allowed origins is buggy, the list is bypassed even though it exists:

// WRONG: both evilyour-domain.uz and your-domain.uz.evil.example pass
if (origin.endsWith('your-domain.uz') || origin.includes('your-domain.uz')) { ... }

// WRONG: unescaped dot, no anchors
/https:\/\/.*.your-domain.uz/

The correct approach is exact matching against a list, or a strictly anchored regex:

/^https:\/\/([a-z0-9-]+\.)?your-domain\.uz$/

One more note: do not put http:// (unencrypted) origins on the production list — an attacker on the network can inject content on their behalf.

How to test with curl

Send a request with a foreign origin and look at the Access-Control-* headers in the response:

curl -si https://api.your-domain.uz/v1/me \
  -H "Origin: https://evil.example" | grep -i access-control

curl -si https://api.your-domain.uz/v1/me \
  -H "Origin: null" | grep -i access-control

curl -si https://api.your-domain.uz/v1/me \
  -H "Origin: https://your-domain.uz.evil.example" | grep -i access-control

Test the preflight (OPTIONS) request too — the browser sends it before PUT, DELETE or requests with custom headers:

curl -si -X OPTIONS https://api.your-domain.uz/v1/profile \
  -H "Origin: https://evil.example" \
  -H "Access-Control-Request-Method: DELETE" \
  -H "Access-Control-Request-Headers: authorization" | grep -i access-control

How to read the result:

  • The foreign origin comes back and Allow-Credentials: true is present — a serious finding.
  • Allow-Origin: null comes back — a finding.
  • * comes back — judge it by what data the endpoint returns.
  • No Access-Control-Allow-Origin at all for the foreign origin — correct.

Test all the main endpoints, including login, profile, file downloads and admin APIs. Sometimes CORS is not in shared middleware but hand-written in a single controller.

Correct configuration in Laravel

Laravel handles CORS through the built-in HandleCors middleware and config/cors.php (in Laravel 11+ you can publish the file with php artisan config:publish cors):

return [
    'paths' => ['api/*', 'sanctum/csrf-cookie'],
    'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
    'allowed_origins' => [
        'https://app.your-domain.uz',
        'https://admin.your-domain.uz',
    ],
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With', 'X-XSRF-TOKEN'],
    'exposed_headers' => [],
    'max_age' => 600,
    'supports_credentials' => true,
];

Note:

  • '*' in allowed_origins and supports_credentials => true must not be combined.
  • Reading origins from .env per environment is convenient, but the production value must be an exact list of domains.
  • If you use allowed_origins_patterns, anchor the regex with ^ and $ and escape the dots.

Correct configuration in Node (Express)

const allowed = new Set([
  'https://app.your-domain.uz',
  'https://admin.your-domain.uz',
]);

app.use(cors({
  origin(origin, cb) {
    // No Origin — a non-browser request (curl, server); no CORS header needed
    if (!origin) return cb(null, false);
    cb(null, allowed.has(origin));
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  maxAge: 600,
}));

With a dynamic origin, the cors package adds a Vary: Origin header. This matters: otherwise a proxy or CDN may cache the response for one origin and serve it to another.

At the nginx level

If nginx adds the CORS headers, do not echo $http_origin directly — check it with a map first:

map $http_origin $cors_origin {
    default "";
    "https://app.your-domain.uz"   $http_origin;
    "https://admin.your-domain.uz" $http_origin;
}

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

nginx does not send an add_header whose value is empty, so origins not on the list get no header. Configure CORS in one place: if both the application and nginx add it, duplicate headers appear and the browser rejects the response.

For cookie-based APIs, CORS is closely tied to CSRF: CORS controls reading the response but does not stop the request from being sent. State-changing requests therefore need separate protection with a CSRF token or SameSite cookies. We covered where to store tokens in the JWT article, and the general API list in the OWASP API Top 10 article.

A CORS allowlist is a decision about who you trust. It should be short, exact, and every line in it should be justified.

Checklist

  • Allowed origins are an exact list; Origin is never reflected.
  • * and Allow-Credentials: true are never combined; no * on private APIs.
  • No null or http:// origins on the list.
  • Regex checks are anchored with escaped dots; no endsWith/includes.
  • Vary: Origin is sent when the origin is dynamic.
  • Allowed methods and headers are minimal.
  • CORS is configured in one place (the app or nginx).
  • The API checks authorization on every request, independently of CORS.
  • curl tests with foreign, null and look-alike origins have been run.

Conclusion

A CORS mistake lives not in the code but in a single line of configuration, often one that was added "temporarily". Finding and fixing it takes minutes — it just needs to happen before the expertise.

Want to check which origins your API trusts? Request a security scan — the Claude AI agent reviews your configuration and code, and our specialist confirms the findings and helps you close them.