Explain the CORS preflight OPTIONS flow: when the browser sends it, what headers are exchanged, and how Spring handles it.
answer
- simple = GET/HEAD/POST + safelisted headers + form/text type
- JSON, PUT/DELETE, custom headers => preflight
- OPTIONS carries Request-Method + Request-Headers
- Spring answers preflight itself; handler not invoked
- Security filter can block OPTIONS before MVC
basics
~20 sFor non-simple requests (e.g. PUT/DELETE, JSON content type, or custom headers), the browser first sends an OPTIONS 'preflight' asking permission via Access-Control-Request-Method/Headers. The server answers with Access-Control-Allow-* headers. Spring answers this automatically from your CORS config — your controller isn't invoked.
solid answer
~40 sThe browser sends a preflight when a request isn't 'simple.' Simple requests are GET/HEAD/POST with only CORS-safelisted headers and a content type of application/x-www-form-urlencoded, multipart/form-data, or text/plain. Anything else — PUT/DELETE/PATCH, application/json, custom headers like Authorization — triggers an OPTIONS preflight first. The preflight carries Origin, Access-Control-Request-Method, and Access-Control-Request-Headers. The server must respond (2xx) with Access-Control-Allow-Origin, -Allow-Methods, -Allow-Headers, and, if applicable, -Allow-Credentials and -Max-Age. If it matches, the browser proceeds with the real request; if not, the real request is never sent. Spring MVC intercepts preflight OPTIONS via its CORS processor (before your handler) and answers from the resolved CorsConfiguration, so your @RequestMapping method is not invoked for the OPTIONS itself. Max-Age lets the browser cache the preflight result to avoid re-asking.
code
java · 19 lines// Wire so the DELETE below is preflighted and answered by Spring automatically.
@Configuration
class WebConfig implements WebMvcConfigurer {
@Override public void addCorsMappings(CorsRegistry r) {
r.addMapping("/api/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Authorization", "Content-Type")
.maxAge(3600);
}
}
@RestController
class ItemController {
// Browser sends OPTIONS preflight first (DELETE + Authorization are non-simple).
// Spring answers the OPTIONS; this method runs only for the real DELETE.
@DeleteMapping("/api/items/{id}")
void delete(@PathVariable long id) { /* ... */ }
}go deeper
Know that some requests send an OPTIONS 'permission check' first and Spring answers it.
Know the simple-vs-preflight criteria and the Request/Allow header pairs.
Explain the Security-filter-ordering trap and maxAge caching tradeoffs.
Reason about preflight latency at scale (maxAge, header minimization) and the auth/CORS boundary ownership.
## Simple vs. preflighted requests The CORS spec splits cross-origin requests into two classes. **Simple requests** skip preflight. A request is 'simple' only if **all** hold: - Method is **GET, HEAD, or POST**. - Only **CORS-safelisted request headers** are set (Accept, Accept-Language, Content-Language, and a restricted Content-Type). - **Content-Type**, if present, is one of `application/x-www-form-urlencoded`, `multipart/form-data`, or `text/plain`. - No `ReadableStream` body, no event listeners on the upload object, etc. **Preflighted requests** are everything else — the common triggers in a JSON API are: - Methods **PUT, PATCH, DELETE**. - **`Content-Type: application/json`** (not on the safelist). - **Custom headers** such as `Authorization`, `X-Requested-With`, `X-CSRF-Token`. ## The preflight exchange Before the real call, the browser sends an **OPTIONS** request: ``` OPTIONS /api/items/42 HTTP/1.1 Origin: https://app.example.com Access-Control-Request-Method: DELETE Access-Control-Request-Headers: authorization, content-type ``` The server replies (must be a 2xx with no body needed): ``` HTTP/1.1 200 OK Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: authorization, content-type Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 ``` - If the requested method/headers are covered, the browser sends the **actual** request. - If not, the browser **aborts** — the real request is never sent, and JS sees a CORS error. - `Access-Control-Max-Age` tells the browser how long to **cache** this preflight so it need not re-ask for every call (browsers also cap this; e.g. Chromium caps around 2 hours). ## How Spring handles it Spring MVC's `DispatcherServlet`/handler mapping runs CORS processing (`DefaultCorsProcessor`) **before** dispatching to your controller: - For a **preflight OPTIONS**, Spring detects `Access-Control-Request-Method`, resolves the applicable `CorsConfiguration` (from `@CrossOrigin` and/or global mappings), writes the allow headers, and **returns the response itself** — your `@RequestMapping` method is *not* invoked. - For the **actual** request, Spring validates the origin against the config and adds `Access-Control-Allow-Origin` (and `-Expose-Headers`, `-Allow-Credentials`) to the real response, then invokes your handler normally. A disallowed origin is rejected with 403 by the CORS processor. You therefore never write an explicit `@RequestMapping(method=OPTIONS)` for CORS — Spring owns it. ## Gotchas - **Spring Security ordering**: Security filters run before the DispatcherServlet. If Security blocks the unauthenticated OPTIONS (it carries no cookies/credentials by design), the preflight fails with 401/403 before MVC's CORS processor runs. Fix: enable Security's own CORS (`http.cors(...)`) so it handles/permits preflight. `@CrossOrigin` alone does not. - **Preflight has no credentials**: browsers never send cookies on the OPTIONS preflight, so don't require auth on it. - **Custom headers must be listed**: if the client sends `Authorization`, the server's `Access-Control-Allow-Headers` must include it (or `*`, which is invalid together with credentials). - **maxAge tuning**: too low = a preflight per call (latency); browsers cap the max anyway. ## When it matters Any SPA calling a JSON API with `Authorization` headers or non-GET verbs will preflight — so preflight handling is not an edge case, it's the normal path.
- A POST with Content-Type application/json — does it preflight? What about application/x-www-form-urlencoded?application/json is not on the CORS-safelist, so yes it preflights. application/x-www-form-urlencoded (or multipart/form-data or text/plain) is safelisted, so a plain POST with it and no custom headers is a simple request and does not preflight.
- Preflight returns 401 even though your CORS config looks correct. Likely cause?Spring Security is intercepting the unauthenticated OPTIONS before MVC's CORS processor. Enable http.cors() with a CorsConfigurationSource so Security handles preflight and permits it.
saying these in an interview costs you the question
- Claiming every cross-origin request triggers a preflight
- Writing an explicit @RequestMapping(method=OPTIONS) handler for CORS
- Thinking the browser sends cookies on the preflight
- Believing a POST is always simple regardless of content type