Understanding CORS for API Developers

Understanding CORS for API Developers

A valid fetch call may return a 200 OK status on the network tab but trigger a browser error:

Access to fetch at 'https://api.example.com/users' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.

To resolve this, you must understand the interaction between the browser's security policies and server response headers.


Same-Origin Policy (SOP)

Same-Origin Policy (SOP) is the browser security mechanism that restricts how JavaScript on one origin interacts with resources from another. An origin is the combination of:

ComponentExample
Protocolhttps://
Domainapi.example.com
Port:443

All three must match. If any differ, the request is cross-origin and subject to SOP.

FromToSame origin?
http://localhost:3000http://localhost:8080❌ Different port
https://app.comhttps://api.app.com❌ Different subdomain
https://app.comhttp://app.com❌ Different protocol

SOP prevents malicious sites from exploiting the fact that browsers automatically attach credentials (cookies) to requests. If you are logged into bank.com, the browser sends your session cookies with every request to that domain. Without SOP, a malicious site could trigger a background request to bank.com/transfer and the browser would include your credentials, authorizing the action without your knowledge.


Simple vs Non-Simple Requests

Browsers handle cross-origin requests based on their complexity.

Simple Requests

A request is "simple" if:

  • The method is GET, POST, or HEAD.
  • Headers are limited to Accept, Accept-Language, Content-Language, or Content-Type (with values text/plain, multipart/form-data, or application/x-www-form-urlencoded).
  • It uses no custom headers.

Simple requests are sent directly to the server without a handshake.

Non-Simple Requests

Any request that falls outside the simple criteria is "non-simple." Examples include:

  • Methods: PUT, DELETE, PATCH.
  • Content-Type: application/json.
  • Custom headers: Authorization, X-Api-Key.

Non-simple requests trigger a preflight OPTIONS request. The browser verifies server permission before sending the actual request.


Response Enforcement

The browser does not block a request from leaving the machine or reaching the server. It blocks JavaScript access to the response.

Simple Request Flow

  1. Browser sends request.
  2. Server responds.
  3. Browser checks for Access-Control-Allow-Origin in the response.
    • Match: JavaScript can read the response.
    • No match: Browser blocks access.

Non-Simple Request Flow

  1. Browser sends OPTIONS preflight.
  2. Server responds with CORS headers.
    • Invalid/Missing: Browser stops; actual request is never sent.
    • Valid: Browser sends the actual request.
  3. Server responds to actual request.
  4. Browser checks CORS headers again.
    • Match: JavaScript can read the response.
    • No match: Browser blocks access.

CORS Headers

Cross-Origin Resource Sharing (CORS) is the mechanism servers use to opt-in to cross-origin access.

Standard Response Header

Access-Control-Allow-Origin: https://app.com

Use * only for public APIs without credentials.

Preflight Headers

OPTIONS responses for non-simple requests must include:

Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type

Credentials

If the request includes cookies or Authorization, the server must specify the exact origin and set:

Access-Control-Allow-Credentials: true

The wildcard * is not permitted when credentials are included.


Debugging with API Clients

SOP is a browser-only restriction. Tools like curl, Postman, or PostPilot bypass these checks because they are not browsers. If a request works in PostPilot but fails in a web app, the issue is a CORS misconfiguration on the backend.


Step-by-Step Debugging

1. Identify Request Type

Determine if your request is simple or non-simple. If it includes Authorization or application/json, you must debug the preflight first.

2. Verify Preflight (Non-Simple Only)

Manually trigger the preflight using curl:

curl -X OPTIONS https://api.example.com/users \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: GET" \
  -H "Access-Control-Request-Headers: Authorization" \
  -v

Ensure the response contains the required Access-Control-Allow-* headers. In PostPilot, paste the curl command directly into the URL field; it automatically parses the command into a request. Send it and inspect the Headers tab to verify the response.

3. Verify Actual Response

Ensure the final response (not just the preflight) also contains Access-Control-Allow-Origin.


Solutions by Use Case

First-Party: Proxy (Same Origin)

If you control both the frontend and backend, the standard solution is to serve them from the same origin. By using a reverse proxy (e.g., Nginx) or a framework proxy (e.g., Nuxt/Vite dev proxy), you can map the backend to a relative path.

  • Frontend: https://app.com
  • Backend API: https://app.com/api

Since they share the same origin, SOP does not apply and no CORS configuration is required.

Third-Party: CORS Configuration

If your frontend and backend must reside on different origins (e.g., app.com and api.com), you must configure the backend to explicitly allow cross-origin access.

Backend Configuration Examples

Quarkus

quarkus.http.cors=true
quarkus.http.cors.origins=http://localhost:3000
quarkus.http.cors.methods=GET,POST,PUT,DELETE
quarkus.http.cors.headers=Authorization,Content-Type

Spring Boot

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins("http://localhost:3000")
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .allowedHeaders("*");
    }
}

Express (Node.js)

const cors = require('cors');
app.use(cors({
  origin: 'http://localhost:3000',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Authorization', 'Content-Type'],
}));

Summary

  1. SOP is the default: Browsers deny cross-origin access by default.
  2. CORS is the relaxation: Servers must explicitly grant permission via headers.
  3. Preflights are required for non-simple requests: The OPTIONS request must succeed before the target request is sent.
  4. Headers are checked twice: Both preflight and actual responses require valid CORS headers.
  5. API clients bypass SOP: Success in PostPilot confirms the backend is functional, and the error is purely configuration-based.