DEV Community

Cover image for CORS: The Silent Guardian You Keep Fighting Against
Jack Pritom Soren
Jack Pritom Soren

Posted on

CORS: The Silent Guardian You Keep Fighting Against

The Story Begins in the Early Web

Picture the early internet. The year is 2005. JavaScript is becoming powerful. Websites are starting to make network requests dynamically using XMLHttpRequest. Developers are excited. And then someone realizes something terrifying.

Imagine you're logged into your online banking portal at https://mybank.com. While that tab is open, you accidentally click a link and land on a malicious page at https://evil-hacker.com. That malicious page runs some JavaScript in the background — silently — and makes a request to https://mybank.com/transfer?amount=10000&to=hacker. Because your browser already has the authentication cookies for mybank.com, the request goes through with your credentials attached.

You just got robbed. Without clicking anything. Without knowing anything. That is the Same-Origin Policy problem that CORS was born to solve.


What Is the Same-Origin Policy (SOP)?

Before understanding CORS, you must understand the wall it's built on top of — the Same-Origin Policy.

The Same-Origin Policy is a fundamental security mechanism built into every web browser. It says:

A web page can only read data from a response if that response comes from the same origin as the page itself.

An "origin" is defined by three things working together:

Protocol + Host + Port

So https://myapp.com:443 is a completely different origin from:

  • http://myapp.com:443 — different protocol
  • https://api.myapp.com:443 — different subdomain (different host)
  • https://myapp.com:3000 — different port
  • https://anotherapp.com:443 — different domain entirely

The browser enforces this silently and aggressively. If you're on https://myapp.com and your JavaScript tries to read a response from https://api.myapp.com, the browser will block the response from being read by your JavaScript code. The request still goes out — the server still processes it — but the browser throws the data in the trash before your code can see it.

This is brilliant for security. It stops the bank robbery scenario completely. But it creates an enormous problem for modern web development.


The Modern Web's Problem: We Need Cross-Origin Requests

Modern applications are built on separation. Your frontend lives at https://myapp.com. Your API lives at https://api.myapp.com. Your auth server lives at https://auth.myapp.com. Your CDN is at https://cdn.cloudflare.com. You're calling third-party APIs like Stripe, Twilio, Google Maps.

Every single one of these is a cross-origin request.

The Same-Origin Policy, if left completely unmodified, would make modern web applications completely impossible. You couldn't build a React frontend that talks to a Node.js API. You couldn't integrate any third-party service. The entire microservices ecosystem would collapse.

The web needed a way to relax the Same-Origin Policy in a controlled, secure, explicit manner. That solution is CORS — Cross-Origin Resource Sharing.


What Is CORS, Actually?

CORS is not a security threat. CORS is not a bug. CORS is the solution.

CORS is a system — defined by the W3C and implemented by browsers — that allows servers to explicitly declare which outside origins are permitted to read their responses.

It works through a set of HTTP headers. The server uses these headers to tell the browser:

"Hey browser, I trust https://myapp.com. If a JavaScript request comes from there, go ahead and let the code read my response."

Without these headers, the browser defaults to the Same-Origin Policy — block everything from outside origins. With these headers, the server grants specific, explicit permission.

The key insight: CORS is enforced by the browser, not the server. The server doesn't block anything. The browser does. This is why CORS errors don't appear in Postman, curl, or server-to-server requests. Those tools don't implement the browser's Same-Origin Policy. Only browsers do.


The CORS Flow: What Actually Happens

Simple Requests

Some requests are classified as "simple" — they use common methods (GET, POST, HEAD) and only standard headers. For these, the browser sends the request directly with an Origin header:

GET /api/users HTTP/1.1
Host: api.myapp.com
Origin: https://myapp.com
Enter fullscreen mode Exit fullscreen mode

The server responds with:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.com
Content-Type: application/json
Enter fullscreen mode Exit fullscreen mode

The browser looks at Access-Control-Allow-Origin. If it matches the requesting origin (or is a wildcard *), the browser allows the JavaScript to read the response. If it's missing or doesn't match — the browser discards the response and throws a CORS error in the console. The request still happened. The data came back. But your JavaScript never sees it.

Preflight Requests — The Real Complexity

For anything more complex — PUT, DELETE, PATCH methods, custom headers like Authorization, Content-Type: application/json — the browser doesn't send the actual request first. It sends a preflight — an OPTIONS request — to ask the server for permission before sending the real request.

OPTIONS /api/users/123 HTTP/1.1
Host: api.myapp.com
Origin: https://myapp.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization, Content-Type
Enter fullscreen mode Exit fullscreen mode

The browser is essentially saying: "Hey, I have a client who wants to send a DELETE request with Authorization and Content-Type headers. Is that okay with you?"

The server must respond to this preflight with permission headers:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Enter fullscreen mode Exit fullscreen mode

Only after the preflight succeeds does the browser send the real request. If the preflight fails, the real request never gets sent.

Access-Control-Max-Age tells the browser how long it can cache this preflight response (in seconds) so it doesn't need to send a new OPTIONS request every single time.


The Most Important CORS Headers Explained

Access-Control-Allow-Origin

This is the core header. It tells the browser which origin is allowed to read the response.

Access-Control-Allow-Origin: * means any origin can read this response (fine for public APIs, dangerous for authenticated endpoints).

Access-Control-Allow-Origin: https://myapp.com means only this specific origin can read it.

Critical gotcha: You cannot use * together with Access-Control-Allow-Credentials: true. It's either a wildcard (no credentials) or a specific origin (with credentials). The browser enforces this.

Access-Control-Allow-Methods

Lists which HTTP methods are permitted for cross-origin requests.

Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS
Enter fullscreen mode Exit fullscreen mode

Access-Control-Allow-Headers

Lists which request headers the client is allowed to send.

Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With
Enter fullscreen mode Exit fullscreen mode

Access-Control-Allow-Credentials

When set to true, this tells the browser that cross-origin requests can include cookies, HTTP authentication, and client-side SSL certificates.

Access-Control-Allow-Credentials: true
Enter fullscreen mode Exit fullscreen mode

When you use this, Access-Control-Allow-Origin must be a specific origin — not *. The browser will reject the response otherwise.

Access-Control-Expose-Headers

By default, JavaScript can only read a few "safe" response headers. If you want to expose custom headers (like a pagination token or rate limit info) to the client-side JavaScript, you must explicitly expose them.

Access-Control-Expose-Headers: X-Total-Count, X-Rate-Limit-Remaining
Enter fullscreen mode Exit fullscreen mode

Access-Control-Max-Age

How many seconds the browser should cache the preflight response. Reduces unnecessary OPTIONS requests.

Access-Control-Max-Age: 3600
Enter fullscreen mode Exit fullscreen mode

When Does the CORS Error Actually Happen?

The error appears in your browser console when:

Scenario 1 — Missing CORS headers entirely. Your server doesn't send any Access-Control-Allow-Origin header. The browser sees nothing and blocks the response.

Scenario 2 — Wrong origin in the header. Your server sends Access-Control-Allow-Origin: https://myapp.com but the request comes from https://localhost:3000 during development. Mismatch. Blocked.

Scenario 3 — Preflight fails. Your DELETE or PUT request requires a preflight. Your server doesn't handle OPTIONS requests and returns a 404 or 405. The real request never fires.

Scenario 4 — Credentials mismatch. You're sending credentials: 'include' in your fetch call (to send cookies), but your server either has Access-Control-Allow-Origin: * or doesn't have Access-Control-Allow-Credentials: true. Blocked.

Scenario 5 — Header not in allowed list. You're sending a custom header like X-Custom-Token but the server's Access-Control-Allow-Headers doesn't include it. Blocked.


Solving CORS in Express.js (Node.js)

The fastest and most reliable way is using the cors npm package.

Install the Package

npm install cors
Enter fullscreen mode Exit fullscreen mode

Basic Setup — Allow Everything (Development Only)

const express = require('express');
const cors = require('cors');

const app = express();

// Allow ALL origins — use only in development
app.use(cors());

app.get('/api/data', (req, res) => {
  res.json({ message: 'Hello from the API' });
});

app.listen(5000);
Enter fullscreen mode Exit fullscreen mode

This is fine for local development but never use this in production on an authenticated API. Anyone from any origin can access your endpoints.

Production Setup — Specific Origins

const express = require('express');
const cors = require('cors');

const app = express();

const corsOptions = {
  origin: 'https://myapp.com',       // Only allow this origin
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,                  // Allow cookies and auth headers
  maxAge: 86400                       // Cache preflight for 24 hours
};

app.use(cors(corsOptions));
Enter fullscreen mode Exit fullscreen mode

Production Setup — Multiple Allowed Origins

Most real apps need to allow multiple origins — production, staging, maybe a mobile app domain.

const express = require('express');
const cors = require('cors');

const app = express();

const allowedOrigins = [
  'https://myapp.com',
  'https://staging.myapp.com',
  'https://admin.myapp.com'
];

const corsOptions = {
  origin: function (origin, callback) {
    // Allow requests with no origin (like mobile apps, Postman, curl)
    if (!origin) return callback(null, true);

    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`CORS policy: Origin ${origin} is not allowed`));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
  credentials: true,
  maxAge: 86400
};

app.use(cors(corsOptions));
Enter fullscreen mode Exit fullscreen mode

Environment-Aware CORS Configuration

Load allowed origins from environment variables so you don't hardcode them:

const express = require('express');
const cors = require('cors');

const app = express();

// In .env file: ALLOWED_ORIGINS=https://myapp.com,https://staging.myapp.com
const allowedOrigins = process.env.ALLOWED_ORIGINS
  ? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim())
  : ['http://localhost:3000'];

const corsOptions = {
  origin: function (origin, callback) {
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 3600
};

app.use(cors(corsOptions));

// This is CRITICAL — explicitly handle preflight OPTIONS requests
app.options('*', cors(corsOptions));
Enter fullscreen mode Exit fullscreen mode

The app.options('*', cors(corsOptions)) line is important. It ensures that all preflight requests across all routes are properly handled before the route-specific middleware runs.

Route-Specific CORS

Sometimes different routes need different CORS policies — a public /api/public endpoint can allow all origins while /api/admin should be restricted.

const express = require('express');
const cors = require('cors');

const app = express();

const publicCors = cors({ origin: '*' });

const privateCors = cors({
  origin: 'https://admin.myapp.com',
  credentials: true
});

// Public endpoint — anyone can call this
app.get('/api/public/products', publicCors, (req, res) => {
  res.json({ products: [] });
});

// Private endpoint — only admin dashboard can call this
app.get('/api/admin/users', privateCors, (req, res) => {
  res.json({ users: [] });
});
Enter fullscreen mode Exit fullscreen mode

Manual CORS Without the Package

If you want full control without a library:

const express = require('express');
const app = express();

app.use((req, res, next) => {
  const origin = req.headers.origin;
  const allowedOrigins = ['https://myapp.com', 'http://localhost:3000'];

  if (allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
  }

  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  res.setHeader('Access-Control-Allow-Credentials', 'true');
  res.setHeader('Access-Control-Max-Age', '86400');

  // Handle preflight
  if (req.method === 'OPTIONS') {
    return res.status(204).end();
  }

  next();
});
Enter fullscreen mode Exit fullscreen mode

Solving CORS in Spring Boot (Java)

Spring Boot gives you several clean ways to configure CORS, ranging from a single annotation on a controller to a global configuration that covers your entire API.

Method 1 — @CrossOrigin Annotation on a Controller

The simplest approach. Add the annotation directly to a controller or a specific method.

import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = "https://myapp.com")
public class UserController {

    @GetMapping
    public List<User> getAllUsers() {
        return userService.findAll();
    }

    // Only this specific method allows a different origin
    @GetMapping("/public")
    @CrossOrigin(origins = "*")
    public List<User> getPublicUsers() {
        return userService.findPublic();
    }
}
Enter fullscreen mode Exit fullscreen mode

You can customize it further:

@CrossOrigin(
    origins = {"https://myapp.com", "https://staging.myapp.com"},
    methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE},
    allowedHeaders = {"Content-Type", "Authorization"},
    allowCredentials = "true",
    maxAge = 3600
)
Enter fullscreen mode Exit fullscreen mode

This approach is fine for small APIs but gets repetitive fast — you'd need to add it to every controller.

Method 2 — Global CORS with WebMvcConfigurer

This is the recommended approach for most Spring Boot applications. Define CORS in one place, apply it everywhere.

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")       // Apply to all routes under /api/
            .allowedOrigins(
                "https://myapp.com",
                "https://staging.myapp.com"
            )
            .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
            .allowedHeaders("Content-Type", "Authorization", "X-Requested-With")
            .allowCredentials(true)
            .maxAge(3600);
    }
}
Enter fullscreen mode Exit fullscreen mode

To load origins from environment variables (the production-ready approach):

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Value("${app.cors.allowed-origins}")
    private String[] allowedOrigins;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins(allowedOrigins)
            .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
            .allowedHeaders("*")
            .allowCredentials(true)
            .maxAge(86400);
    }
}
Enter fullscreen mode Exit fullscreen mode

In application.properties:

app.cors.allowed-origins=https://myapp.com,https://staging.myapp.com
Enter fullscreen mode Exit fullscreen mode

In application.yml:

app:
  cors:
    allowed-origins:
      - https://myapp.com
      - https://staging.myapp.com
Enter fullscreen mode Exit fullscreen mode

Method 3 — CORS with Spring Security (The Critical One)

This is the most important one to get right. If your application uses Spring Security, you must configure CORS through Spring Security — not just through WebMvcConfigurer. If you don't, Spring Security's filter chain will reject cross-origin requests before they even reach your CORS configuration.

The wrong way (extremely common mistake):

// This alone is NOT enough when Spring Security is present
@Override
public void addCorsMappings(CorsRegistry registry) {
    registry.addMapping("/**").allowedOrigins("*"); // Won't work with Spring Security!
}
Enter fullscreen mode Exit fullscreen mode

The right way — configure CORS at the Security level:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .csrf(csrf -> csrf.disable()) // Disable CSRF for stateless REST APIs
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            );

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        configuration.setAllowedOrigins(Arrays.asList(
            "https://myapp.com",
            "https://staging.myapp.com"
        ));

        configuration.setAllowedMethods(Arrays.asList(
            "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"
        ));

        configuration.setAllowedHeaders(Arrays.asList(
            "Authorization",
            "Content-Type",
            "X-Requested-With",
            "Accept",
            "Origin"
        ));

        configuration.setExposedHeaders(Arrays.asList(
            "X-Total-Count",
            "Authorization"
        ));

        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);

        return source;
    }
}
Enter fullscreen mode Exit fullscreen mode

Using Pattern-Based Origin Matching in Spring Boot

If you need to allow all subdomains of a domain, Spring Boot supports pattern matching:

configuration.setAllowedOriginPatterns(Arrays.asList(
    "https://*.myapp.com",       // Any subdomain of myapp.com
    "http://localhost:[*]"        // Any localhost port (great for dev)
));
Enter fullscreen mode Exit fullscreen mode

Note: Use setAllowedOriginPatterns instead of setAllowedOrigins when using patterns. You cannot mix wildcard * with allowCredentials(true) when using setAllowedOrigins — use allowedOriginPatterns for that combination.


Common Mistakes and How to Avoid Them

Mistake 1 — Thinking CORS is a Server-Side Security Feature

CORS is enforced by the browser. Curl, Postman, and server-to-server calls completely bypass it. If someone bypasses the browser (as any attacker would), CORS provides zero protection. CORS is about protecting the user's browser, not your server. Actual server security (authentication, authorization, rate limiting) must still be implemented independently.

Mistake 2 — Using * with Credentials

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Enter fullscreen mode Exit fullscreen mode

The browser will reject this combination. Always. You must specify an explicit origin when using credentials.

Mistake 3 — Forgetting to Handle OPTIONS Preflight

Your POST, PUT, DELETE requests will fire a preflight OPTIONS request first. If your server doesn't respond correctly to OPTIONS (some servers return 404 or 405 for OPTIONS by default), the real request never goes through. Always make sure OPTIONS is handled at the top of your middleware chain.

Mistake 4 — Proxy in Development, Forgetting for Production

During development, many developers use a proxy (like Vite's proxy config or Create React App's proxy field) to avoid CORS issues. This works because the proxy makes server-to-server calls — no browser CORS. Then they deploy to production, remove the proxy, and suddenly everything breaks because production doesn't have proper CORS headers on the server. Configure CORS properly on the server from day one.

Mistake 5 — Trailing Slashes in Origin

https://myapp.com and https://myapp.com/ are treated differently by some CORS implementations. Browsers send origins without trailing slashes. Make sure your allowed origins list doesn't have trailing slashes.


A Note on CORS and Local Development

During local development, your frontend likely runs on http://localhost:3000 while your backend runs on http://localhost:5000 or http://localhost:8080. These are different origins (different ports), so CORS applies even for localhost.

The cleanest solutions:

Option A — Add http://localhost:3000 to your allowed origins with an environment variable so it's only allowed in development.

Option B — Use a Vite or webpack proxy to have all API requests go through the same origin during development:

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Option C — Run both frontend and backend under the same origin using nginx in a Docker Compose setup that mirrors production.


The Mental Model That Makes It All Click

Think of CORS like a doorman at an exclusive building. The Same-Origin Policy is the building's default rule: nobody from outside gets in. CORS is the list the doorman checks. If your name (origin) is on the list (the server's Access-Control-Allow-Origin header), you get in. If not, the doorman (browser) turns you away — even if you somehow already received the package (the server responded successfully). The doorman doesn't care what the server wants. The doorman enforces the list.

Your job as a backend developer is to put the right names on the list — specific, trusted origins — and make sure the doorman knows about credentials, allowed methods, and how long to remember the last check.

When done right, CORS is invisible. When done wrong, it's the most maddening error message in frontend development. But now you know the whole story — why it exists, what it protects, how it works, and exactly how to configure it in both Express.js and Spring Boot.

Configure it right once, and you'll never fight it again.


Built with care for developers who've spent too long googling the same error message at 2am.

Follow me on : Github Linkedin Threads Youtube Channel

Top comments (0)