CORS (Cross-Origin Resource Sharing)

Understanding and configuring cross-origin requests in web applications

← Back to Index

What is CORS?

CORS (Cross-Origin Resource Sharing) is one of the most commonly encountered—and often frustrating—concepts for web developers. It's a browser security mechanism that controls how web pages can request resources from a different domain (origin) than the one that served the page.

If you've ever seen the dreaded error message "has been blocked by CORS policy", you've experienced this security mechanism in action. Understanding CORS is essential because it affects virtually every modern web application that separates frontend and backend.

Why Does CORS Exist? The Security Problem

To understand CORS, you first need to understand the threat it protects against. Imagine this scenario:

  1. You're logged into your bank at https://mybank.com
  2. In another tab, you visit a malicious site https://evil-site.com
  3. Without security restrictions, JavaScript on evil-site.com could:
    • Make requests to https://mybank.com/api/transfer
    • Read your account information
    • Transfer money to the attacker's account
    • All using YOUR authenticated session (because cookies are sent automatically)

This is why browsers implement the Same-Origin Policy—JavaScript can only make requests to the same origin that served the page, unless the target server explicitly allows it via CORS.

The Same-Origin Policy: By default, browsers block JavaScript from making requests to different origins. This is a critical security measure that prevents malicious websites from accessing your data on other sites.

CORS (Cross-Origin Resource Sharing): The mechanism by which servers can explicitly say "I allow requests from these origins." It's not about making your app work—it's about security. CORS is the server giving permission to the browser.

Key Concept: The Browser Enforces CORS, Not the Server

This is a crucial point that confuses many developers: CORS is enforced by the browser, not the server. The server just sends headers saying what it allows—the browser decides whether to block or allow based on those headers.

What is an "Origin"?

// An origin consists of: Protocol + Host + Port

https://example.com:443/path/page
└─┬─┘   └────┬────┘ └┬┘
  │         │       └── Port (443 is default for HTTPS)
  │         └────────── Host (domain)
  └──────────────────── Protocol (scheme)

// Same origin examples:
https://example.com/page1
https://example.com/page2     ✓ Same origin (different path)

// Different origin examples:
https://example.com
http://example.com            ✗ Different protocol
https://api.example.com       ✗ Different subdomain
https://example.com:8080      ✗ Different port
https://other.com             ✗ Different domain

The CORS Problem

// Scenario: Frontend at https://myapp.com, API at https://api.myapp.com

// Frontend JavaScript tries to fetch data:
fetch('https://api.myapp.com/users')
    .then(response => response.json())
    .then(data => console.log(data));

// Browser blocks it!
// Error: "Access to fetch at 'https://api.myapp.com/users' from origin
//        'https://myapp.com' has been blocked by CORS policy"

// Why? Different subdomains = different origins!

┌─────────────────────────────────────────────────────────────────┐
│                         BROWSER                                  │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  Page loaded from: https://myapp.com                     │   │
│  │                                                          │   │
│  │  JavaScript tries: fetch('https://api.myapp.com/users')  │   │
│  │                           │                              │   │
│  │                           ▼                              │   │
│  │              ┌───────────────────────┐                   │   │
│  │              │   CORS CHECK          │                   │   │
│  │              │   Origin: myapp.com   │                   │   │
│  │              │   Target: api.myapp   │                   │   │
│  │              │   ────────────────    │                   │   │
│  │              │   DIFFERENT! BLOCK!   │                   │   │
│  │              └───────────────────────┘                   │   │
│  └─────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘

How CORS Works

Simple Requests

For "simple" requests (GET, POST with simple content types), the browser sends the request but blocks the response if CORS headers are missing.

// 1. Browser sends request with Origin header
GET /api/users HTTP/1.1
Host: api.myapp.com
Origin: https://myapp.com      ← Browser adds this automatically

// 2. Server responds with CORS headers
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.com  ← Server allows this origin
Content-Type: application/json

[{"id": 1, "name": "John"}]

// 3. Browser checks: Does Allow-Origin match our origin?
//    Yes! Response is allowed through to JavaScript

Preflight Requests

For "non-simple" requests (PUT, DELETE, custom headers, JSON content-type), the browser first sends an OPTIONS request to check if the actual request is allowed.

// Triggering a preflight:
fetch('https://api.myapp.com/users', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',  ← Triggers preflight!
        'Authorization': 'Bearer token'       ← Custom header triggers preflight!
    },
    body: JSON.stringify({ name: 'John' })
});

// STEP 1: Browser sends preflight OPTIONS request
OPTIONS /api/users HTTP/1.1
Host: api.myapp.com
Origin: https://myapp.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

// STEP 2: Server responds to preflight
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400  ← Cache preflight for 24 hours

// STEP 3: If preflight passes, browser sends actual request
POST /api/users HTTP/1.1
Host: api.myapp.com
Origin: https://myapp.com
Content-Type: application/json
Authorization: Bearer token

{"name": "John"}

// STEP 4: Server responds with data + CORS headers
HTTP/1.1 201 Created
Access-Control-Allow-Origin: https://myapp.com
Content-Type: application/json

{"id": 1, "name": "John"}
What Triggers a Preflight?
  • Methods other than GET, HEAD, POST
  • Content-Type other than: application/x-www-form-urlencoded, multipart/form-data, text/plain
  • Custom headers (Authorization, X-Custom-Header, etc.)

CORS Headers Explained

Header Example Purpose
Access-Control-Allow-Origin https://myapp.com or * Which origins can access the resource
Access-Control-Allow-Methods GET, POST, PUT, DELETE Which HTTP methods are allowed
Access-Control-Allow-Headers Content-Type, Authorization Which request headers are allowed
Access-Control-Allow-Credentials true Allow cookies/auth with requests
Access-Control-Expose-Headers X-Custom-Header Headers JavaScript can read
Access-Control-Max-Age 86400 How long to cache preflight (seconds)

Configuring CORS in Spring Boot

Method 1: @CrossOrigin Annotation

// Allow CORS for specific controller
@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = "https://myapp.com")  ← Entire controller
public class UserController {

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

    // Or per method with different settings
    @CrossOrigin(
        origins = {"https://myapp.com", "https://admin.myapp.com"},
        methods = {RequestMethod.GET, RequestMethod.POST},
        allowedHeaders = {"Content-Type", "Authorization"},
        maxAge = 3600
    )
    @PostMapping
    public User createUser(@RequestBody User user) {
        return userService.save(user);
    }
}

Method 2: Global CORS Configuration

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")  // Apply to all /api/ endpoints
            .allowedOrigins(
                "https://myapp.com",
                "https://admin.myapp.com"
            )
            .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
            .allowedHeaders("*")
            .allowCredentials(true)
            .maxAge(3600);

        // Different config for public endpoints
        registry.addMapping("/public/**")
            .allowedOrigins("*")  // Allow all origins
            .allowedMethods("GET");
    }
}

Method 3: CORS Filter (Most Control)

@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();

        // Allowed origins
        config.setAllowedOrigins(Arrays.asList(
            "https://myapp.com",
            "https://admin.myapp.com"
        ));

        // Or use patterns for dynamic origins
        config.setAllowedOriginPatterns(Arrays.asList(
            "https://*.myapp.com"  // Any subdomain
        ));

        // Allowed methods
        config.setAllowedMethods(Arrays.asList(
            "GET", "POST", "PUT", "DELETE", "OPTIONS"
        ));

        // Allowed headers
        config.setAllowedHeaders(Arrays.asList(
            "Content-Type",
            "Authorization",
            "X-Requested-With"
        ));

        // Exposed headers (readable by JavaScript)
        config.setExposedHeaders(Arrays.asList(
            "X-Total-Count",
            "X-Page-Number"
        ));

        // Allow credentials (cookies, auth headers)
        config.setAllowCredentials(true);

        // Preflight cache duration
        config.setMaxAge(3600L);

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

        return new CorsFilter(source);
    }
}

Method 4: With Spring Security

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // Enable CORS with default configuration
            .cors(Customizer.withDefaults())

            // Or with custom configuration
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))

            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            );

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("https://myapp.com"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
        config.setAllowedHeaders(List.of("*"));
        config.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}

CORS with Credentials

When you need to send cookies or authentication headers with cross-origin requests, special configuration is required.

// Frontend: Must include credentials option
fetch('https://api.myapp.com/users', {
    method: 'GET',
    credentials: 'include'  ← Include cookies!
});

// Or with axios
axios.get('https://api.myapp.com/users', {
    withCredentials: true
});

// Backend: Must allow credentials AND cannot use wildcard origin!

// WRONG - Won't work with credentials!
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

// CORRECT - Specific origin required
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Credentials: true
Credentials Rule

When Access-Control-Allow-Credentials: true, you cannot use * for Allow-Origin. You must specify the exact origin(s).

Dynamic Origin with Credentials

// Allow multiple origins while supporting credentials
@Component
public class DynamicCorsFilter extends OncePerRequestFilter {

    private static final Set<String> ALLOWED_ORIGINS = Set.of(
        "https://myapp.com",
        "https://admin.myapp.com",
        "https://mobile.myapp.com"
    );

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                      HttpServletResponse response,
                                      FilterChain chain) throws Exception {

        String origin = request.getHeader("Origin");

        if (origin != null && ALLOWED_ORIGINS.contains(origin)) {
            // Echo back the specific requesting origin
            response.setHeader("Access-Control-Allow-Origin", origin);
            response.setHeader("Access-Control-Allow-Credentials", "true");
            response.setHeader("Access-Control-Allow-Methods",
                "GET, POST, PUT, DELETE, OPTIONS");
            response.setHeader("Access-Control-Allow-Headers",
                "Content-Type, Authorization");
            response.setHeader("Access-Control-Max-Age", "3600");
        }

        // Handle preflight
        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            response.setStatus(HttpServletResponse.SC_OK);
            return;
        }

        chain.doFilter(request, response);
    }
}

Common CORS Errors and Solutions

Error 1: No 'Access-Control-Allow-Origin' header

// Error message:
// "No 'Access-Control-Allow-Origin' header is present on the requested resource"

// Cause: Server not sending CORS headers
// Solution: Add CORS configuration to your backend

@CrossOrigin(origins = "https://myapp.com")
@GetMapping("/api/data")
public Data getData() { ... }

Error 2: Preflight request doesn't pass

// Error message:
// "Response to preflight request doesn't pass access control check"

// Cause: OPTIONS request failing or not handled
// Solution: Ensure OPTIONS method is allowed

config.setAllowedMethods(Arrays.asList(
    "GET", "POST", "PUT", "DELETE",
    "OPTIONS"  ← Don't forget this!
));

Error 3: Credentials not supported with wildcard

// Error message:
// "The value of 'Access-Control-Allow-Origin' header must not be '*'
//  when credentials mode is 'include'"

// Cause: Using * with credentials: 'include'
// Solution: Specify exact origin

// WRONG
config.setAllowedOrigins(List.of("*"));
config.setAllowCredentials(true);

// CORRECT
config.setAllowedOrigins(List.of("https://myapp.com"));
config.setAllowCredentials(true);

Error 4: Header not allowed

// Error message:
// "Request header field X-Custom-Header is not allowed"

// Cause: Custom header not in Allow-Headers
// Solution: Add the header to allowed headers

config.setAllowedHeaders(Arrays.asList(
    "Content-Type",
    "Authorization",
    "X-Custom-Header"  ← Add your custom header
));

// Or allow all headers
config.setAllowedHeaders(List.of("*"));

Environment-Specific CORS

// Different CORS settings per environment

@Configuration
public class CorsConfig {

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

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(Arrays.asList(allowedOrigins));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
        config.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return new CorsFilter(source);
    }
}

// application-dev.properties
cors.allowed-origins=http://localhost:3000,http://localhost:5173

// application-prod.properties
cors.allowed-origins=https://myapp.com,https://admin.myapp.com

CORS vs CSRF

Aspect CORS CSRF
Purpose Control cross-origin requests Prevent forged requests
Enforced By Browser Server
Protection Prevents reading cross-origin responses Prevents unauthorized actions
Mechanism HTTP headers Tokens, SameSite cookies
Important!

CORS does NOT prevent requests from being sent - it only prevents the browser from reading the response. A malicious site can still send requests (which might trigger side effects). Use CSRF protection for state-changing operations!

Testing CORS

// Using curl to test CORS headers

# Simple request
curl -v -H "Origin: https://myapp.com" \
     https://api.myapp.com/api/users

# Preflight request
curl -v -X OPTIONS \
     -H "Origin: https://myapp.com" \
     -H "Access-Control-Request-Method: POST" \
     -H "Access-Control-Request-Headers: Content-Type, Authorization" \
     https://api.myapp.com/api/users

# Check response headers:
# < Access-Control-Allow-Origin: https://myapp.com
# < Access-Control-Allow-Methods: GET, POST, PUT, DELETE
# < Access-Control-Allow-Headers: Content-Type, Authorization
# < Access-Control-Allow-Credentials: true

Browser DevTools

// In browser console, check for CORS errors:

// 1. Open DevTools (F12)
// 2. Go to Network tab
// 3. Make your request
// 4. Click on the request
// 5. Check "Headers" tab for:
//    - Request Headers: Origin
//    - Response Headers: Access-Control-*
// 6. Check Console tab for CORS error messages

Best Practices

DO:

  • Be specific with origins - List exact allowed origins
  • Use HTTPS - Both for origin and API
  • Set Max-Age - Cache preflight responses
  • Limit allowed methods - Only what you need
  • Configure differently per environment - Stricter in production
  • Use allowedOriginPatterns for subdomains - https://*.myapp.com

DON'T:

  • Don't use * in production - Too permissive
  • Don't use * with credentials - Doesn't work anyway
  • Don't forget OPTIONS method - Needed for preflight
  • Don't rely on CORS alone for security - Add authentication!
  • Don't expose sensitive headers unnecessarily

Summary

  • CORS: Browser security mechanism for cross-origin requests
  • Origin: Protocol + Host + Port must all match
  • Preflight: OPTIONS request for "non-simple" requests
  • Allow-Origin: Which origins can access the resource
  • Credentials: Cannot use * with credentials
  • Configuration: @CrossOrigin, WebMvcConfigurer, or Filter
  • Security: CORS prevents reading responses, not sending requests