What is CORS — and What Problem Does It Actually Solve?
CORS (Cross-Origin Resource Sharing) is a browser-enforced security mechanism that controls whether JavaScript on one origin is allowed to read responses from a different origin. It exists as a controlled relaxation of the Same-Origin Policy — the rule that says JavaScript can only read responses from the exact same origin (protocol + host + port) that served the page.
The threat the Same-Origin Policy defends against is concrete:
// The attack CORS prevents at the browser level
// 1. You are logged into https://mybank.com — your session cookie is in the browser
// 2. You open a new tab and visit https://evil.example.com
// 3. evil.example.com's JavaScript runs this:
fetch('https://mybank.com/api/transfer', {
method: 'POST',
credentials: 'include', // sends YOUR session cookie to the bank
body: JSON.stringify({ to: 'attacker-account', amount: 10000 })
});
// Without Same-Origin Policy: the bank receives a valid authenticated request
// and processes the transfer — you never clicked anything.
// With Same-Origin Policy: the browser blocks evil.example.com from reading
// the bank's response. The request MAY still be sent (this is where CSRF
// protection comes in — covered in Sessions & Cookies), but reading
// account data cross-origin is prevented entirely.
CORS is enforced by the browser, not the server.
The server doesn't block anything — it tells the browser what it
permits, and the browser decides whether to hand the response to
JavaScript. When you test with curl or Postman, you
never see a CORS error because neither tool implements the
Same-Origin Policy. CORS errors are exclusively a browser
phenomenon. The request very often does reach your
server — the browser just refuses to give the response to the
JavaScript that initiated it.
What "Origin" Means Precisely
// Origin = Protocol + Host + Port — all three must match
https://shop.example.com:443/products/123
└──┬──┘ └────────┬────────┘ └─┬─┘
protocol host port
// Same origin as https://shop.example.com
https://shop.example.com/cart // ✓ same (different path is irrelevant)
https://shop.example.com:443/other // ✓ same (443 is the default for HTTPS)
// Different origin — any single element differs
http://shop.example.com // ✗ different protocol
https://api.example.com // ✗ different subdomain → different host
https://shop.example.com:8080 // ✗ different port
https://example.com // ✗ different host (no subdomain)
https://shop.othersite.com // ✗ different domain entirely
The CORS Handshake: Simple Requests and Preflight
Simple Requests — No Preflight
A request is "simple" (no preflight) when all three conditions hold:
the method is GET, HEAD, or POST; the Content-Type is
one of application/x-www-form-urlencoded,
multipart/form-data, or text/plain; and no
custom headers are added. Simple requests existed before CORS and
must keep working, so they go through directly — the browser just
checks the response headers before handing the response to JavaScript.
// Browser sends request with Origin header
GET /api/products HTTP/1.1
Host: api.shop.example.com
Origin: https://shop.example.com // added automatically by the browser
Accept: application/json
// Server responds — the CORS header is what the browser checks
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://shop.example.com
Content-Type: application/json
[{"id": 1, "name": "Widget"}]
// Browser checks: does Allow-Origin match the page's origin?
// Yes → JavaScript gets the response
// No → Browser throws CORS error, JavaScript never sees the body
Preflight — The Hidden Round Trip
Any request that isn't "simple" triggers a preflight: the browser
sends an OPTIONS request first to ask "will you accept
this?" before sending the actual request. This adds a full network
round trip before every non-trivial API call — understanding this is
important for performance, not just for debugging.
// This fetch triggers a preflight — Authorization and Content-Type: application/json
// are both outside the "simple" definitions
fetch('https://api.shop.example.com/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json', // triggers preflight
'Authorization': 'Bearer eyJ...' // custom header → preflight
},
body: JSON.stringify(orderData)
});
// ── Step 1: Browser sends OPTIONS ────────────────────────────────────
OPTIONS /api/orders HTTP/1.1
Host: api.shop.example.com
Origin: https://shop.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
// ── Step 2: Server approves the preflight ────────────────────────────
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 3600 // cache this answer for 1 hour
// ── Step 3: Only now does the actual POST go ─────────────────────────
POST /api/orders HTTP/1.1
Host: api.shop.example.com
Origin: https://shop.example.com
Content-Type: application/json
Authorization: Bearer eyJ...
{"productId": 42, "quantity": 1}
Access-Control-Max-Age is a free performance winSetting Max-Age tells the browser to cache the
preflight result — subsequent requests to the same origin use
the cached answer instead of sending another OPTIONS
round trip. The browser maximum is 7200 seconds (Chrome/Firefox);
3600 is a common production value. Without it, every
authenticated API call sends two HTTP requests instead of one.
On a mobile connection with 150ms RTT, that's 150ms of latency
paid before every single request that carries a custom header.
CORS Headers Reference
| Header | Direction | Purpose |
|---|---|---|
Origin | Request | The requesting origin — set by the browser, never by you |
Access-Control-Allow-Origin | Response | Which origin(s) may read the response. Either a specific origin or * |
Access-Control-Allow-Methods | Preflight response | Which HTTP methods are allowed |
Access-Control-Allow-Headers | Preflight response | Which request headers are allowed |
Access-Control-Allow-Credentials | Response | Whether cookies/auth may be sent. * for origin is forbidden when this is true |
Access-Control-Expose-Headers | Response | Which response headers JavaScript is allowed to read. Default: only the "safe" headers |
Access-Control-Max-Age | Preflight response | How long to cache the preflight result in seconds |
Access-Control-Request-Method | Preflight request | The method the browser is asking permission for |
Access-Control-Request-Headers | Preflight request | The headers the browser is asking permission for |
Access-Control-Expose-Headers catches people off guardBy default, JavaScript can only read a small set of "safe"
response headers: Cache-Control,
Content-Language, Content-Length,
Content-Type, Expires,
Last-Modified, Pragma. Everything else
— including your pagination headers like
X-Total-Count, Location on a 201, or
any custom header — is invisible to JavaScript unless you
explicitly expose it. This is a very common production bug:
"The header is in the response but my code can't read it."
Configuring CORS in Spring Boot — Which Method, When
Spring offers four configuration surfaces for CORS. The choice matters — using the wrong one in combination with Spring Security is one of the most common sources of intermittent CORS failures in production.
Option 1: @CrossOrigin — Controller or Method Level
Use this for one-off exceptions to your global policy, or in simple projects without Spring Security. It's the most visible (the policy is right next to the code it affects) and the least maintainable at scale (scattered across dozens of controllers).
@RestController
@RequestMapping("/api/products")
@CrossOrigin(
origins = { "https://shop.example.com", "https://admin.example.com" },
allowedHeaders = { "Content-Type", "Authorization" },
methods = { RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE },
maxAge = 3600
)
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping
public List<ProductResponse> getProducts() {
return productService.findAll();
}
}
Option 2: WebMvcConfigurer — Global MVC CORS Policy
The correct default for apps without Spring Security. One place, all endpoints, no duplication.
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Value("${cors.allowed-origins}")
private List<String> allowedOrigins;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins(allowedOrigins.toArray(new String[0]))
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
.allowedHeaders("Content-Type", "Authorization")
.exposedHeaders("X-Total-Count", "Location")
.allowCredentials(true)
.maxAge(3600);
// Public read-only endpoints — wider permission
registry.addMapping("/api/public/**")
.allowedOriginPatterns("*")
.allowedMethods("GET")
.maxAge(3600);
}
}
# application-dev.properties
cors.allowed-origins=http://localhost:3000,http://localhost:5173
# application-prod.properties
cors.allowed-origins=https://shop.example.com,https://admin.example.com
Option 3: CorsConfigurationSource Bean — With Spring Security
This is the only correct approach when Spring Security is in
the classpath. Spring Security's filter chain runs
before DispatcherServlet, which means Spring MVC's
WebMvcConfigurer CORS configuration never executes for
requests that Spring Security intercepts first. If you use
WebMvcConfigurer with Spring Security, OPTIONS preflight
requests are blocked at the security filter before your CORS config
ever runs — the browser sees a 401 or 403 on the preflight, the
actual request never fires, and you spend an hour debugging a "CORS
error" that is really an authentication misconfiguration.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final List<String> allowedOrigins;
public SecurityConfig(@Value("${cors.allowed-origins}") List<String> allowedOrigins) {
this.allowedOrigins = allowedOrigins;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// Point Spring Security at our CorsConfigurationSource bean below.
// This handles OPTIONS preflights BEFORE authentication runs —
// if you omit this line, preflights get a 401 and nothing works.
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable()) // stateless JWT API — no session, no CSRF needed
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(allowedOrigins);
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Content-Type", "Authorization"));
config.setExposedHeaders(List.of("X-Total-Count", "Location"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
Which Option to Use
| Situation | Use |
|---|---|
| No Spring Security, simple project | WebMvcConfigurer.addCorsMappings() |
| Spring Security in the classpath | CorsConfigurationSource bean + http.cors(cors -> cors.configurationSource(...)) |
| One endpoint needs a different policy from the rest | @CrossOrigin on that method only, overriding the global policy |
| Testing / local only | Profile-specific properties, not hardcoded * in code |
Credentials and the Wildcard Constraint
When the frontend needs to send cookies or an Authorization
header in a cross-origin request, both sides must opt in, and the
combination of allowCredentials=true with
allowedOrigins=["*"] is explicitly forbidden by the
spec.
// Frontend — must set credentials: 'include' explicitly
fetch('https://api.shop.example.com/api/profile', {
credentials: 'include' // sends cookies cross-origin; without this, cookies are omitted
});
// Backend — the two rules that BOTH must hold simultaneously
// WRONG — browser rejects this combination
config.setAllowedOrigins(List.of("*"));
config.setAllowCredentials(true); // ✗ spec-forbidden, browser ignores Allow-Credentials
// CORRECT — explicit origin required when credentials are in play
config.setAllowedOrigins(List.of("https://shop.example.com"));
config.setAllowCredentials(true); // ✓
allowedOrigins("*") as a quick fix for credentials errorsWhen a developer gets the credentials mode is 'include'
browser error and adds allowedOrigins("*") trying to
open everything up, the browser still rejects it — the spec
explicitly forbids * with credentials. The fix is
not a wider wildcard; it is specifying the exact origin. This
is the most common "I tried everything and CORS is still broken"
loop.
Wildcard with allowedOriginPatterns
If you genuinely need to allow an entire domain family
(e.g., all preview deployments under
*.preview.shop.example.com),
allowedOriginPatterns supports wildcards and remains
compatible with credentials because it resolves to a specific
origin at request time.
// Allows any subdomain of preview.shop.example.com, with credentials
config.setAllowedOriginPatterns(List.of("https://*.preview.shop.example.com"));
config.setAllowCredentials(true); // ✓ — the browser gets back the specific requesting origin
// The response header echoes the actual requesting origin, not the pattern:
// Access-Control-Allow-Origin: https://feat-123.preview.shop.example.com
The Four CORS Errors You Will Definitely See
1. "No Access-Control-Allow-Origin header present"
// Full error:
// Access to fetch at 'https://api.shop.example.com/api/orders'
// from origin 'https://shop.example.com' has been blocked by CORS policy:
// No 'Access-Control-Allow-Origin' header is present on the requested resource.
// Cause: server sends no CORS headers — either not configured or wrong config method
// Debug: curl -v -H "Origin: https://shop.example.com" https://api.shop.example.com/api/orders
// If no Access-Control-* headers appear in the response, the config isn't applying.
// If Spring Security is present: are you using WebMvcConfigurer instead of CorsConfigurationSource?
2. "Preflight response doesn't pass access control check"
// Cause: the OPTIONS preflight is getting a non-2xx response, typically 401 or 403
// from Spring Security intercepting it before CORS runs.
// Debug: curl -v -X OPTIONS -H "Origin: https://shop.example.com"
// -H "Access-Control-Request-Method: POST"
// -H "Access-Control-Request-Headers: Content-Type,Authorization"
// https://api.shop.example.com/api/orders
// If you see 401: Spring Security is rejecting the preflight before CORS processes it.
// Fix: use CorsConfigurationSource with http.cors(...) so Spring Security handles
// CORS before authentication — preflights have no credentials to authenticate with.
3. "The value of Allow-Origin must not be '*' when credentials mode is 'include'"
// Cause: allowedOrigins("*") combined with allowCredentials(true)
// This is spec-forbidden — the browser enforces it regardless of server config.
config.setAllowedOrigins(List.of("https://shop.example.com")); // specify exactly
config.setAllowCredentials(true);
4. "Request header field X-Custom-Header is not allowed"
// Cause: a header your frontend sends is not in allowedHeaders on the server
// Every non-simple header must be explicitly listed (or use allowedHeaders("*"))
config.setAllowedHeaders(List.of(
"Content-Type",
"Authorization",
"X-Request-Id" // add whatever you send from the frontend
));
// Note: allowedHeaders("*") is fine for development; be explicit in production
// to avoid accidentally allowing headers that could carry sensitive data.
CORS vs CSRF — They're Not the Same Problem
| Aspect | CORS | CSRF |
|---|---|---|
| Protects against | Reading cross-origin responses (data theft) | Forged cross-origin state-changing requests (unwanted actions) |
| Enforced by | Browser (checks response headers) | Server (checks tokens or cookie attributes) |
| What it blocks | JavaScript reading the response | The request itself being processed as legitimate |
| Mechanism | Access-Control-* response headers | CSRF tokens, SameSite cookie attribute |
A malicious page can still successfully send a POST to your server
and trigger side effects — CORS only prevents the attacker's
JavaScript from reading the response. If the harm comes
from the action itself (a transfer, a state change, a deletion),
not from reading data, CORS gives you no protection against that.
That's what CSRF tokens and SameSite cookies defend
against. The two mechanisms are complementary, not alternatives.
For a complete treatment see
Sessions & Cookies.
Debugging CORS Efficiently
# Step 1: test a simple GET with an Origin header
curl -v -H "Origin: https://shop.example.com" \
https://api.shop.example.com/api/products
# Look for in the response headers:
# Access-Control-Allow-Origin: https://shop.example.com ← config is working
# (nothing) ← config is NOT applying
# Step 2: test the preflight OPTIONS directly
curl -v -X OPTIONS \
-H "Origin: https://shop.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization" \
https://api.shop.example.com/api/orders
# Expected: 204 No Content with Access-Control-Allow-* headers
# 401/403: Spring Security is intercepting before CORS runs → use CorsConfigurationSource
# 404: Your OPTIONS endpoint isn't mapped → add OPTIONS to allowedMethods
# 200 without headers: CORS config not applied → check which config method you're using
Filter by "Preflight" or look for the OPTIONS request before your
actual request. Click it, inspect the Response Headers tab — the
Access-Control-* headers (or their absence) tell you
exactly what the server sent and why the browser rejected it. The
Console tab shows the exact CORS error message, which is more
specific than it looks once you know what each phrase means.
Best Practices and Common Pitfalls
✅ Do
- Use
CorsConfigurationSourcebean +http.cors(cors -> cors.configurationSource(...))the moment Spring Security is in the classpath — nothing else works reliably - Set
maxAgeto at least 600 seconds (Chrome's minimum for the cache to take effect) — every API call without it costs an extra round trip - Expose headers your frontend reads via
setExposedHeaders—Location, pagination headers, rate-limit headers are all invisible by default - Drive allowed origins from environment-specific properties — never hardcode production origins in code that also runs in development
- Use
allowedOriginPatternsfor wildcard subdomain needs, notallowedOrigins("*"), so it remains compatible with credentials
❌ Don't
- Don't use
WebMvcConfigurer.addCorsMappings()with Spring Security — preflights get 401 before your config runs - Don't combine
allowedOrigins("*")withallowCredentials(true)— it's spec-forbidden and the browser will refuse it regardless of what the server sends - Don't think a CORS error means the request didn't reach the server — it usually did; CORS only controls whether JavaScript can read the response
- Don't use
allowedOrigins("*")in production for any endpoint that handles user data — it allows any JavaScript on any page to read your responses - Don't confuse CORS (reading cross-origin responses) with CSRF (forged cross-origin requests) — they're different threats, different defenses, both necessary
Interview Questions
Q: What is CORS and why does it exist?
CORS (Cross-Origin Resource Sharing) is a browser mechanism that
controls whether JavaScript on one origin can read responses from
a different origin. It exists as a controlled relaxation of the
Same-Origin Policy, which prevents malicious pages from reading
data from other sites using the visitor's credentials.
Q: Why does a CORS error not appear when testing with curl but does appear in the browser?
CORS is enforced by the browser, not the server. curl
doesn't implement the Same-Origin Policy — it sends requests and
reads responses like any other program. The browser adds the
Origin header automatically and checks the response
headers before giving the response to JavaScript. The request often
reaches the server either way; it's the browser that decides whether
JavaScript can see the response.
Q: What is a preflight request?
A preflight is an OPTIONS request the browser sends
automatically before a non-simple cross-origin request (any request
with a custom header like Authorization, or with
Content-Type: application/json). It asks the server
"will you accept this?" before sending the actual request. If the
server doesn't respond with the right Access-Control-*
headers, the actual request is never sent.
Q: You configure CORS with WebMvcConfigurer.addCorsMappings(), but preflight requests keep returning 401. Spring Security is in the classpath. What's happening?
Spring Security's filter chain runs before the DispatcherServlet,
so it intercepts every request — including OPTIONS preflights —
before Spring MVC's CORS configuration ever executes. The preflight
has no authentication credentials (it's an automated browser probe,
not a user request), so it fails the security check and returns
401. The fix is to configure CORS at the Spring Security layer via
a CorsConfigurationSource bean and
http.cors(cors -> cors.configurationSource(...)). This
makes Spring Security handle CORS before authentication, so
preflights are permitted without credentials, and only the actual
requests need to authenticate.
Q: A developer sets allowedOrigins("*") and allowCredentials(true) to fix a CORS error with cookies. The browser still rejects it. Why, and what is the correct fix?
The combination is explicitly forbidden by the CORS specification.
When credentials are involved (cookies, auth headers), the
browser requires Access-Control-Allow-Origin to
contain the exact requesting origin, not a wildcard — otherwise
any site on the internet could make authenticated requests using
the visitor's cookies. The browser enforces this regardless of
what the server sends. The correct fix is to list the allowed
origins explicitly: allowedOrigins("https://shop.example.com").
If multiple origins need to be supported with credentials,
use allowedOriginPatterns (which resolves to a specific
origin at request time and is allowed with credentials) or maintain
an explicit allowlist.
Q: Your API responds with a Location header on 201 Created, and your React frontend reads it to navigate to the new resource. It works in local development but breaks in production. What's the likely cause?
By default, CORS only exposes a small set of "safe" response
headers to JavaScript: Cache-Control,
Content-Language, Content-Length,
Content-Type, Expires,
Last-Modified, and Pragma. The
Location header is not in this list. In local
development, if both the frontend and API run on the same origin,
CORS doesn't apply and all headers are visible. In production,
with separated origins, JavaScript can't read Location
unless the server explicitly includes it in
Access-Control-Expose-Headers. Fix:
config.setExposedHeaders(List.of("Location")) in
your CORS configuration.