PCSalt
YouTube GitHub
Back to Security
Security · 3 min read

CORS Explained — Why Your API Rejects Frontend Requests

Understand CORS from the ground up — what it is, why browsers enforce it, preflight requests, how to configure it in Spring Boot, and common debugging tips.


You build an API. It works in Postman. You call it from your React app and get:

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.

This is CORS — Cross-Origin Resource Sharing. It’s not a bug, it’s a browser security feature. And understanding it takes 10 minutes.

What is CORS?

Browsers enforce the Same-Origin Policy: JavaScript on http://localhost:3000 can only make requests to http://localhost:3000. Any request to a different origin (different protocol, domain, or port) is a cross-origin request and is blocked by default.

CORS is the mechanism that lets servers explicitly allow cross-origin requests from specific origins.

What counts as a different origin?

Two URLs have the same origin only if the protocol, domain, and port all match:

URL AURL BSame origin?
http://example.comhttp://example.com/apiYes
http://example.comhttps://example.comNo (protocol)
http://example.comhttp://api.example.comNo (domain)
http://localhost:3000http://localhost:8080No (port)

How CORS works

Simple requests

For GET, HEAD, or POST with simple headers, the browser sends the request directly with an Origin header:

GET /api/users HTTP/1.1
Host: api.example.com
Origin: http://localhost:3000

The server responds with (or without) CORS headers:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Content-Type: application/json

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

If Access-Control-Allow-Origin matches the request’s Origin, the browser allows the response. If it’s missing or doesn’t match, the browser blocks it.

Preflight requests

For “non-simple” requests (PUT, DELETE, custom headers like Authorization, Content-Type: application/json), the browser sends a preflight OPTIONS request first:

OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

The server responds with what’s allowed:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

If the preflight is approved, the browser sends the actual request. If not, the browser blocks it — the actual request never happens.

What triggers a preflight?

  • Methods other than GET, HEAD, POST
  • POST with Content-Type other than application/x-www-form-urlencoded, multipart/form-data, text/plain
  • Custom headers (Authorization, X-Custom-Header)

In practice, almost every API call from a modern frontend triggers a preflight (because of Content-Type: application/json or Authorization headers).

CORS headers

HeaderPurposeExample
Access-Control-Allow-OriginWhich origins can accesshttp://localhost:3000 or *
Access-Control-Allow-MethodsAllowed HTTP methodsGET, POST, PUT, DELETE
Access-Control-Allow-HeadersAllowed request headersContent-Type, Authorization
Access-Control-Allow-CredentialsAllow cookies/authtrue
Access-Control-Max-AgeCache preflight (seconds)86400 (24 hours)
Access-Control-Expose-HeadersHeaders the client can readX-Total-Count

Configuring CORS in Spring Boot

Global configuration

@Configuration
class CorsConfig : WebMvcConfigurer {

    override fun addCorsMappings(registry: CorsRegistry) {
        registry.addMapping("/api/**")
            .allowedOrigins("http://localhost:3000", "https://myapp.com")
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .allowedHeaders("Content-Type", "Authorization")
            .allowCredentials(true)
            .maxAge(86400)
    }
}

Per-controller

@RestController
@CrossOrigin(origins = ["http://localhost:3000"])
@RequestMapping("/api/users")
class UserController {

    @GetMapping
    fun getUsers(): List<User> = userService.getAll()
}

Spring Security CORS

If you use Spring Security, configure CORS in the security chain — otherwise, Security intercepts before your CORS config runs:

@Configuration
class SecurityConfig {

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .cors { cors ->
                cors.configurationSource(corsConfigSource())
            }
            .csrf { it.disable() } // for API-only backends
            // ... other config

        return http.build()
    }

    @Bean
    fun corsConfigSource(): CorsConfigurationSource {
        val config = CorsConfiguration().apply {
            allowedOrigins = listOf("http://localhost:3000", "https://myapp.com")
            allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS")
            allowedHeaders = listOf("Content-Type", "Authorization")
            allowCredentials = true
            maxAge = 86400
        }

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

Common mistakes

1. Using * with credentials

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

This doesn’t work. When credentials is true, the origin must be explicit — * is rejected by the browser.

Fix: List specific origins:

allowedOrigins = listOf("http://localhost:3000", "https://myapp.com")
allowCredentials = true

2. Missing OPTIONS handling

The preflight OPTIONS request must return 200/204 with CORS headers. If your server returns 401 (because an auth filter runs first), CORS fails.

Fix: Ensure OPTIONS requests bypass authentication:

http.authorizeHttpRequests { auth ->
    auth.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
    auth.requestMatchers("/api/**").authenticated()
}

3. Missing headers in the response

The client sends Authorization, but the server only allows Content-Type:

Access-Control-Allow-Headers: Content-Type
// Missing: Authorization

The preflight fails. Fix: Include all headers the client sends.

4. Different CORS config in dev vs prod

CORS allows http://localhost:3000 in development but not in production. Use environment-specific configuration:

@Value("\${cors.allowed-origins}")
lateinit var allowedOrigins: List<String>
# application-dev.yml
cors:
  allowed-origins: http://localhost:3000,http://localhost:5173

# application-prod.yml
cors:
  allowed-origins: https://myapp.com

5. CORS on the wrong layer

If you use a reverse proxy (Nginx, Cloudflare), CORS headers might need to be set there instead of (or in addition to) your application:

# Nginx CORS config
location /api/ {
    add_header Access-Control-Allow-Origin "https://myapp.com" always;
    add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
    add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;

    if ($request_method = OPTIONS) {
        return 204;
    }

    proxy_pass http://backend:8080;
}

Be careful not to set CORS headers in both the proxy and the application — duplicate headers cause issues.

Debugging CORS

1. Check the browser console

The error message tells you exactly what’s wrong:

  • “No ‘Access-Control-Allow-Origin’ header” → Server isn’t sending CORS headers
  • “origin ‘X’ is not allowed by Access-Control-Allow-Origin” → Origin not in the allowed list
  • “Method PUT is not allowed by Access-Control-Allow-Methods” → Method missing
  • “Request header field Authorization is not allowed” → Header missing

2. Check the preflight

Open browser DevTools → Network tab. Look for an OPTIONS request before your actual request. Check its response headers.

3. Test with curl

# Simulate a preflight
curl -X OPTIONS https://api.example.com/api/users \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization" \
  -v

Check the response headers. If CORS headers are missing, the issue is server-side.

4. Postman works but browser doesn’t

Postman doesn’t enforce CORS — it’s a browser-only feature. If Postman works but the browser doesn’t, that confirms it’s a CORS issue (not a server error).

Summary

  • CORS is a browser security feature — servers configure it, browsers enforce it
  • Simple requests get a direct response with Access-Control-Allow-Origin
  • Preflight requests (OPTIONS) are sent for non-simple requests
  • Configure CORS in Spring Security if you use it — not just WebMvcConfigurer
  • Always allow OPTIONS through authentication filters
  • Use specific origins in production — never * with credentials
  • When debugging, check the OPTIONS response in the Network tab

CORS errors are always configuration issues. The browser tells you exactly what’s wrong — read the error message, check your server’s CORS headers, and fix the mismatch.