skip to content

Why must Spring Security's CorsFilter be ordered before the authorization filter, and how does that affect preflight OPTIONS requests?

level: middleimportance: must knowfreq 65%

answer

  1. preflight OPTIONS carries NO credentials
  2. CorsFilter early, before AuthorizationFilter
  3. short-circuits preflight -> 200, authz never runs
  4. MVC-only CORS -> 401 before controller
  5. no manual permitAll(OPTIONS) needed

basics

~20 s

Browsers send a preflight OPTIONS request with no credentials before the real cross-origin call. Spring Security's CorsFilter runs early, before authorization, so it can answer the preflight and permit it instead of the authorization filter rejecting it as unauthenticated.

solid answer

~40 s

For non-simple cross-origin requests the browser first sends a CORS preflight: an OPTIONS request carrying Origin, Access-Control-Request-Method and Access-Control-Request-Headers, and crucially no Authorization header or cookies. When you call http.cors(), Spring Security inserts its CorsFilter early in the chain, before the authorization filter. The CorsFilter recognizes the preflight, writes the Access-Control-Allow-* response, and short-circuits — the request never reaches authorization. If CORS were instead configured only at the MVC layer, the authorization filter would see an unauthenticated OPTIONS and return 401/403, so the browser would abort before ever sending the real request. Putting the CorsFilter first is exactly what makes preflight work under an otherwise locked-down filter chain, without you having to permitAll the OPTIONS method manually.

code

java · 20 lines
java
// With http.cors(), the CorsFilter handles the preflight before authorization.
// You do NOT need to do this manually:
//   .authorizeHttpRequests(a -> a.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll())
// because a permitAll OPTIONS with no CorsFilter still lacks Access-Control-Allow-* headers.

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .cors(Customizer.withDefaults())               // CorsFilter inserted early
        .authorizeHttpRequests(auth -> auth
            .anyRequest().authenticated());            // preflight still works: it never reaches here
    return http.build();
}

// A preflight the browser sends automatically looks like:
//   OPTIONS /api/orders HTTP/1.1
//   Origin: https://app.example.com
//   Access-Control-Request-Method: PUT
//   Access-Control-Request-Headers: authorization
//   (no Authorization header, no cookies)

go deeper

for a junior

Aware that a preflight OPTIONS happens but may not know why ordering matters.

for a middle

Must explain preflight has no credentials and that CorsFilter runs before authorization to permit it.

for a senior

Can name AuthorizationFilter/CorsFilter and diagnose the 401-on-preflight symptom from MVC-only config.

for a principal

Reasons about filter-chain ordering guarantees and why security-layer CORS is authoritative over MVC-layer handling.

## Simple vs preflighted requests CORS splits requests into two kinds. A **simple request** (GET/HEAD/POST with only safe headers and a simple content type) is sent directly; the browser just checks the response headers afterward. A **preflighted request** (anything with a custom header like `Authorization`, methods like `PUT`/`DELETE`, or JSON content type) triggers a **preflight**: the browser first sends an `OPTIONS` request to the same URL to ask permission. The preflight `OPTIONS` request carries: - `Origin: https://app.example.com` - `Access-Control-Request-Method: PUT` - `Access-Control-Request-Headers: authorization, content-type` and — this is the key point — it **does not carry credentials**: no `Authorization` header, no cookies. The browser deliberately omits them because it hasn't yet been told the cross-origin call is allowed. ## The ordering problem Spring Security is a chain of servlet filters that runs **before** Spring MVC's `DispatcherServlet`. One of the last filters is the **authorization filter** (`AuthorizationFilter`, historically `FilterSecurityInterceptor`) which enforces your `authorizeHttpRequests` rules. If your rule is `anyRequest().authenticated()`, an unauthenticated request is rejected with 401/403. A preflight `OPTIONS` has no credentials, so if it reached the authorization filter it would be rejected — and the browser, seeing a non-2xx preflight, would **abort the real request entirely**. The user sees a generic CORS failure in the console. ## How http.cors() fixes it When you enable `http.cors()`, the `CorsConfigurer` inserts a `CorsFilter` (`org.springframework.web.filter.CorsFilter`) at a fixed **early** position in the security filter chain — before the authentication and authorization filters. On each request the `CorsFilter` uses `DefaultCorsProcessor` to: 1. Check whether it is a CORS request (has an `Origin` header). 2. If it is a **preflight** (`OPTIONS` + `Access-Control-Request-Method`), write the `Access-Control-Allow-*` headers and **stop the chain**, returning 200 immediately. Authorization never runs. 3. If it is an actual cross-origin request, validate the origin, add the response headers, and let the chain continue to authentication/authorization as normal. Because the `CorsFilter` short-circuits the preflight before authorization, you get correct behavior *without* having to add `requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()`. ## What breaks if you get ordering wrong - **CORS only at MVC layer** (`WebMvcConfigurer` / `@CrossOrigin`): the security chain rejects the preflight before MVC sees it. Symptom: preflight returns 401/403, browser reports "No 'Access-Control-Allow-Origin' header". - **Manually permitAll OPTIONS but no CORS filter**: preflight now returns 200 but with *no* `Access-Control-Allow-*` headers, so the browser still blocks. You need the actual CORS headers, which the CorsFilter produces. ## Gotcha: DispatcherServlet also has a CorsFilter path Spring MVC can also handle preflights via `PreFlightRequestHandler`, but that is downstream of the security chain. When Spring Security is present, the authoritative place is `http.cors()` so the handling happens before authorization. ## When to use Whenever a browser SPA on a different origin makes anything other than a trivial GET — i.e. almost always for a JSON API with an `Authorization` header — preflight is involved, so correct CorsFilter ordering is essential.

  • Why doesn't the browser send the Authorization header on the preflight?
    The preflight's whole purpose is to ask permission before exposing the real request. Sending credentials to an origin that hasn't yet allowed the call would leak them, so the spec omits them from the OPTIONS preflight.
  • If you permitAll() the OPTIONS method but don't call http.cors(), does cross-origin work?
    No. The preflight now returns 200 but without any Access-Control-Allow-* headers, so the browser still blocks the real request. You need the CorsFilter to emit those headers.

saying these in an interview costs you the question

  • Believing the preflight carries the Authorization header or cookies
  • Thinking you must manually permitAll OPTIONS when http.cors() is set
  • Configuring CORS only via @CrossOrigin/WebMvcConfigurer and expecting preflight to pass a locked-down chain

context