Designing CORS for a cookie-based (refresh-token) JWT SPA on a separate origin: what must be configured and hardened, and what are the caching and header pitfalls?
answer
- credentials:'include' + Allow-Credentials:true + specific origin
- setExposedHeaders for custom response headers
- setMaxAge caches preflight (browser-capped)
- Vary: Origin prevents cache poisoning
- CORS != CSRF; keep SameSite + same-origin gateway avoids it
basics
~20 sSet an exact origin allowlist (or a tight pattern), setAllowCredentials(true) so cookies flow, and expose only needed response headers via setExposedHeaders. Keep the origin list minimal, pair with SameSite cookies and CSRF defenses, and rely on Vary: Origin for correct caching.
solid answer
~40 sFor a browser SPA on origin A calling an API on origin B with an HttpOnly refresh-token cookie, the browser only sends the cookie cross-origin when the fetch uses credentials: 'include' AND the response carries Access-Control-Allow-Credentials: true with a specific (non-wildcard) Access-Control-Allow-Origin. So: setAllowCredentials(true), a tight setAllowedOrigins/OriginPatterns allowlist, setAllowedHeaders for Authorization/Content-Type, and setExposedHeaders for any custom headers the SPA must read (Access-Control-Expose-Headers). Harden it: never reflect arbitrary origins with credentials; keep the allowlist per-environment; set cookies HttpOnly, Secure, and SameSite appropriately; and remember CORS is not CSRF protection. Spring emits Vary: Origin so shared caches don't leak one origin's allow headers to another. Optionally tune setMaxAge to cache preflights and reduce OPTIONS chatter.
code
java · 17 lines@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com")); // exact, per-env allowlist
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setExposedHeaders(List.of("X-Total-Count")); // SPA can now read this header
config.setAllowCredentials(true); // send the HttpOnly refresh cookie
config.setMaxAge(Duration.ofMinutes(30)); // cache preflight, fewer OPTIONS
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
// Front-end must opt in to credentials:
// fetch("https://api.example.com/me", { credentials: "include" })
// Cookie should be issued HttpOnly; Secure; SameSite=None (cross-site) or Lax (same-site subdomains).go deeper
Unlikely to reason about credentialed CORS, exposed headers, or caching.
Can enable credentials but may miss exposedHeaders, Vary, or the CORS-vs-CSRF distinction.
Handles credentials + allowlist + exposedHeaders and knows CORS isn't CSRF.
Designs the full posture: per-env allowlists, SameSite/cookie flags, cache/Vary correctness, maxAge tuning, and prefers a same-origin gateway to eliminate the risk class.
## The scenario A single-page app is served from `https://app.example.com`; the API lives on `https://api.example.com`. Auth uses a short-lived access token plus an **HttpOnly refresh-token cookie** (the pattern in this codebase). Because the two origins differ, every authenticated call is cross-origin and cookie-bearing, which is the strictest CORS case. ## What the browser requires for cookies to flow cross-origin Three conditions must ALL hold: 1. The client fetch uses `credentials: 'include'` (or `withCredentials = true` for XHR/axios). Without it, the browser neither sends nor accepts cookies. 2. The response has `Access-Control-Allow-Credentials: true` → `config.setAllowCredentials(true)`. 3. The response `Access-Control-Allow-Origin` is a **specific origin**, never `*` (see the wildcard rule). Use `setAllowedOrigins(List.of("https://app.example.com"))` or a tight `setAllowedOriginPatterns`. Miss any one and the cookie is silently dropped or the response is blocked. ## Reading custom response headers: exposedHeaders By default, cross-origin JavaScript can only read a small set of *simple* response headers (`Cache-Control`, `Content-Language`, `Content-Type`, `Expires`, `Last-Modified`, `Pragma`). To let the SPA read anything else — say a custom `X-Total-Count` or a rotation header — you must list it in `config.setExposedHeaders(...)`, which emits `Access-Control-Expose-Headers`. This is a frequent bug: the header is present on the wire (visible in devtools) but `response.headers.get(...)` returns null because it wasn't exposed. ## Preflight caching: maxAge `config.setMaxAge(Duration.ofMinutes(30))` emits `Access-Control-Max-Age`, telling the browser it may cache the preflight result and skip the OPTIONS round-trip for that duration. This reduces latency and load for chatty APIs. Browsers cap the value (Chromium historically ~2 hours), so very large values are clamped. ## Caching correctness: Vary: Origin When you reflect a specific origin, the response is origin-dependent. Spring automatically adds `Vary: Origin` (and `Vary: Access-Control-Request-Method` / `-Headers`). This tells shared caches (CDNs, reverse proxies) to key the cached response by origin, preventing a cache from returning `Access-Control-Allow-Origin: https://a.com` to a request from `https://b.com`. If a misconfigured cache strips `Vary`, you can get cross-origin cache poisoning of the allow headers. ## Security hardening (principal-level) - **Minimal allowlist:** enumerate exactly the origins that need access, per environment (dev/staging/prod). Preview/PR environments may justify a tight `*.preview.example.com` pattern, never a broad `*`. - **Never reflect arbitrary Origin with credentials:** a custom filter that copies the request `Origin` into the allow header with credentials true is equivalent to `*`+credentials and lets any site make authenticated calls. Treat this as a vulnerability. - **CORS is not CSRF protection.** Allowing an origin to *read* responses is different from preventing a malicious site from *making* state-changing requests. Simple requests (form posts, no preflight) can still be forged. Keep CSRF defenses (SameSite=Lax/Strict cookies, CSRF tokens, or requiring a non-simple content type/custom header) regardless of CORS. - **Cookie flags:** refresh cookie should be `HttpOnly` (JS can't read it), `Secure` (HTTPS only), and `SameSite` set deliberately. Cross-site cookie delivery generally requires `SameSite=None; Secure`; if the two origins are same-site (subdomains of one registrable domain) `SameSite=Lax` may still work. This choice interacts directly with whether the CORS-permitted cross-origin call can carry the cookie. - **Least-privilege methods/headers:** list only the HTTP methods and request headers actually used rather than `*` (and `*` for allowed headers is itself illegal with credentials). ## Putting it together ```java config.setAllowedOrigins(List.of("https://app.example.com")); config.setAllowedMethods(List.of("GET","POST","PUT","DELETE")); config.setAllowedHeaders(List.of("Authorization","Content-Type")); config.setExposedHeaders(List.of("X-Total-Count")); config.setAllowCredentials(true); config.setMaxAge(Duration.ofMinutes(30)); ``` ## When to avoid CORS altogether The cleanest hardening is to eliminate cross-origin: serve the SPA and API from the **same origin** behind one reverse proxy / gateway (e.g. `app.example.com` and `app.example.com/api`). Then cookies are first-party, no CORS is needed, and the `*`+credentials and SameSite complications disappear. Many teams choose this specifically to sidestep credentialed-CORS risk.
- The API sends a custom header and you can see it in devtools, but response.headers.get() returns null in the SPA. Why?Cross-origin JS can only read simple response headers unless the header is listed in Access-Control-Expose-Headers. Add it via config.setExposedHeaders(...).
- If CORS restricts which origins can read responses, why do you still need CSRF protection?CORS governs reading responses, not making requests. Simple/forgeable requests (e.g. form POSTs) can still be sent cross-site without a preflight, so you still need SameSite cookies and/or CSRF tokens to prevent state-changing forgery.
- What is the cleanest way to avoid credentialed-CORS complexity entirely?Serve the SPA and API from the same origin behind one gateway/reverse proxy. Cookies become first-party, no CORS is needed, and the *-plus-credentials and SameSite pitfalls disappear.
saying these in an interview costs you the question
- Treating CORS as CSRF protection
- Reflecting arbitrary request Origin with credentials true
- Forgetting setExposedHeaders and expecting JS to read custom headers
- Assuming cookies flow cross-origin without credentials:'include' and SameSite=None/Secure
- Using a broad wildcard origin allowlist in production