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 A | URL B | Same origin? |
|---|---|---|
http://example.com | http://example.com/api | Yes |
http://example.com | https://example.com | No (protocol) |
http://example.com | http://api.example.com | No (domain) |
http://localhost:3000 | http://localhost:8080 | No (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-Typeother thanapplication/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
| Header | Purpose | Example |
|---|---|---|
Access-Control-Allow-Origin | Which origins can access | http://localhost:3000 or * |
Access-Control-Allow-Methods | Allowed HTTP methods | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers | Allowed request headers | Content-Type, Authorization |
Access-Control-Allow-Credentials | Allow cookies/auth | true |
Access-Control-Max-Age | Cache preflight (seconds) | 86400 (24 hours) |
Access-Control-Expose-Headers | Headers the client can read | X-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.