Understanding and Securely Configuring CORS in REST APIs
AI generated
{ }
GET
CORS · Browser Security
Understanding CORS in REST APIs in Depth
Why Access-Control-Allow-Origin: * is almost always the wrong fix for a CORS problem

CORS errors in the browser console rank among the most frustrating debugging experiences for frontend developers, and the obvious, quick fix, setting Access-Control-Allow-Origin to *, usually works immediately but undermines exactly the security mechanism CORS is supposed to provide. A deeper understanding of the underlying preflight and simple request mechanics allows configuring CORS deliberately and securely, instead of disabling it wholesale.

16 min read CORS Browser Security

1. The same-origin policy as the actual root cause of the CORS problem

CORS exists only because browsers enforce the same-origin policy by default, which prevents JavaScript code from one origin (protocol, host, and port combined) from accessing resources of another origin without explicit permission. This policy protects users from a malicious website silently sending requests in the background to other, possibly cookie-authenticated websites and reading their responses, an attack pattern that would be trivially exploitable without the same-origin policy.

CORS is the standardized mechanism through which a server can explicitly permit certain foreign origins to access its resources anyway, by setting corresponding access control headers in its responses. Without these headers, the browser blocks the JavaScript code's access to the response, even if the actual HTTP request was successfully processed on the server.

2. Simple requests vs. preflight requests

Not every cross-origin request triggers the same CORS flow: a simple request (GET, HEAD, POST with certain standard content types like application/x-www-form-urlencoded, without custom headers) is sent directly to the server, and the browser only checks upon receiving the response whether the Access-Control-Allow-Origin headers permit access. For all other requests, especially those with an application/json content type or custom headers like Authorization, the browser first automatically sends an OPTIONS preflight request, to clarify in advance whether the actual request would be allowed at all.

This preflight request contains Access-Control-Request-Method and Access-Control-Request-Headers, telling the server which method and headers the actual, subsequent request would use, and the server must explicitly confirm in its preflight response that this combination is permitted, before the browser sends the actual request at all.


<?php
declare(strict_types=1);

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

final class CorsSubscriber
{
    private const ALLOWED_ORIGINS = [
        'https://app.example.com',
        'https://admin.example.com',
    ];

    public function onKernelRequest(Request $request): ?Response
    {
        if ($request->getMethod() !== 'OPTIONS') {
            return null;
        }

        $origin = $request->headers->get('Origin');
        if (!in_array($origin, self::ALLOWED_ORIGINS, true)) {
            return new Response('', 403);
        }

        return new Response('', 204, [
            'Access-Control-Allow-Origin' => $origin,
            'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE',
            'Access-Control-Allow-Headers' => 'Content-Type, Authorization',
            'Access-Control-Max-Age' => '3600',
        ]);
    }
}

3. Why Access-Control-Allow-Origin: * is almost always wrong

The wildcard value * lets any website on the internet access the API via JavaScript, which can be acceptable for a public, unauthenticated API with purely read-only, non-sensitive data, but poses a significant security risk for any API with authenticated, user-specific data. A malicious website could, in that case, send requests to the actual API in the background and, if session cookies are sent automatically, act on behalf of a logged-in user.

The decisive technical protection against this is that the wildcard * is explicitly NOT combinable with Access-Control-Allow-Credentials: true, the browser actively blocks this combination. So as soon as an API needs cookies, Authorization headers, or other credentials for authenticated requests, Access-Control-Allow-Origin MUST contain an explicit, concrete origin instead of the wildcard, which many developers only learn in practice through a failing request.

4. Correctly handling credentials across cross-origin requests

For cross-origin requests meant to send cookies or other credentials, the client must explicitly set credentials: 'include' in the Fetch API or withCredentials: true with XMLHttpRequest, since credentials are NOT sent automatically by default for cross-origin requests. This explicit opt-in requirement is an additional security layer that prevents credentials from being accidentally sent on every cross-origin request without the developer having deliberately configured it that way.

On the server side, using credentials requires setting both Access-Control-Allow-Origin (with a concrete origin, not a wildcard) and Access-Control-Allow-Credentials: true, otherwise the browser blocks the response despite technically successful server processing, which can lead to confusing errors when only one of the two conditions is met.

5. Dynamic origin validation for multiple allowed domains

With multiple legitimately allowed origins (say, a production app and a separate admin interface under different subdomains), a single static Access-Control-Allow-Origin value isn't enough, since this header can only contain a single origin per response, not a list. The usual solution is to check the incoming request's Origin header against a server-side maintained allowlist and, on a match, dynamically mirror exactly that origin back in the response, as shown in the code example above.

This allowlist should never be implemented via naive prefix or substring checking (such as "ends with .example.com"), since such a check can easily be bypassed by a malicious domain like evil-example.com, but always via an exact string comparison against a complete, explicitly maintained list of allowed origins.

6. Caching preflight results to reduce latency

Every preflight request means an extra roundtrip for the client before the actual request, noticeably contributing to perceived latency, especially for APIs with many consecutive cross-origin calls. The Access-Control-Max-Age header tells the browser how long (in seconds) the result of a preflight request for the same combination of origin, method, and headers may be cached, without triggering another preflight request.

Too low a max-age value forces unnecessarily frequent preflight requests, while too high a value delays the effectiveness of CORS configuration changes, since browsers keep reusing the cached result until it expires. Chrome additionally caps the value at a maximum of two hours, regardless of the value specified in the header.

7. Systematically debugging CORS errors

A CORS error message in the browser console almost always precisely describes which condition wasn't met (missing header, wrong origin value, missing credentials permission), which is why the first debugging step should always be to carefully read the exact error message, instead of hastily reaching for Access-Control-Allow-Origin: *. The network tab in browser DevTools additionally shows whether a preflight request was even sent and which headers it actually contained.

A common, easily overlooked mistake is that the server correctly responds to the actual request with CORS headers, but doesn't correctly handle the preflight OPTIONS request itself (for example because an authentication middleware incorrectly blocks the unauthenticated OPTIONS request before the CORS logic is even reached), which is a separate, often overlooked source of errors beyond the actual CORS header configuration.

8. Correctly passing through CORS headers behind a CDN and reverse proxy

A CDN or reverse proxy in front of the actual API can unintentionally filter out, overwrite, or cache CORS headers, if the proxy configuration doesn't explicitly account for these headers needing to vary dynamically per origin, instead of being statically identical for all requests. A reverse proxy that caches responses without respecting the Vary Origin header can accidentally serve the CORS response computed for origin A to origin B as well, leading to inconsistent, hard-to-trace CORS behavior.

With a CDN in use, it's therefore worth running an explicit test that sends requests from genuinely different origins against the same URL and checks whether the CORS headers correctly vary per origin, instead of blindly relying on a working local development environment without a CDN in front.

9. CORS configuration at a glance

The table below summarizes the key headers and their meaning.

Header Purpose Important note
Access-Control-Allow-Origin Allowed origin(s) for access Never combine * with credentials
Access-Control-Allow-Credentials Allows cookies/auth for cross-origin Requires a concrete origin, no wildcard
Access-Control-Allow-Headers Allowed custom headers in the real request Must cover the preflight request headers
Access-Control-Max-Age Cache duration for preflight result Chrome caps it at max. 2 hours

Mironsoft

OpenAPI design, Symfony APIs, and API security

APIs that external teams can integrate without back-and-forth questions?

We review existing REST APIs for inconsistent error formats, missing OpenAPI documentation, and security gaps, then build an API that is clearly documented, versioned, and hardened against abuse.

API Review

Checking the OpenAPI spec, error formats, and status codes for consistency.

Symfony Implementation

Using DTOs, Serializer, and Validator for clean, type-safe request/response models.

Security Audit

Hardening rate limiting, auth schemes, and input validation against real attack surfaces.

10. Summary

CORS: The Essentials at a Glance

Same-origin policy

CORS exists as a controlled exception mechanism to the browser-side same-origin policy.

Preflight mechanics

Non-simple requests trigger an automatic OPTIONS preflight before the actual request.

Wildcard danger

Access-Control-Allow-Origin: * is incompatible with credentials and unsuitable for authenticated APIs.

Dynamic allowlist

Multiple allowed origins require server-side checking and dynamically mirroring back the origin.

11. FAQ: CORS: The Essentials at a Glance

1Is CORS a server-side or client-side security measure?
Both: the server sets the headers, but the policy is enforced exclusively by the client's browser, not by the server itself.
2Why don't I see the CORS error in tools like Postman?
Because Postman isn't a browser and doesn't enforce the same-origin policy. CORS is a purely browser-based security concept.
3Can I just disable CORS for internal, non-public APIs?
CORS can't be globally disabled, but for APIs used purely server-to-server without browser access, it's irrelevant anyway.
4What happens if I forget Access-Control-Allow-Headers?
The preflight request fails if the actual request contains headers not on this list, such as Authorization.
5Does CORS also solve problems with server-to-server communication?
No, CORS only affects browser-initiated requests. Server-to-server calls aren't affected by the same-origin policy.
6How do I handle CORS for an API with many dynamic subdomains?
Via a pattern check with a correct regex anchor (not naive endsWith), reliably recognizing subdomains of a registered base domain.
7Are CORS errors a sign of a backend bug?
Usually a missing or incorrect CORS configuration, not a functional bug in the API's actual business logic.
8Does every endpoint need to set its own CORS headers?
No, a central middleware or subscriber applying to all endpoints is the usual and lower-maintenance approach.
9How do I test CORS configuration automatically?
With integration tests sending requests with different Origin headers and checking the resulting Access-Control headers.
10Does CORS affect the API's own performance?
Only indirectly through extra preflight roundtrips on the client, the actual server processing time remains unaffected.