MCP Server CORS: The Preflight Problem That Broke My MCP Server 92 Times And How I Fixed It For Good A developer experienced 92 production outages due to CORS preflight failures in an MCP server. The root cause was that the authentication filter ran before the CORS filter, causing OPTIONS preflight requests to be rejected with a 403 before CORS headers were added. The fix was to reorder the filters so that the CORS filter runs first and to allow OPTIONS requests to bypass authentication. Honestly, I thought I understood CORS. I've been building web apps for almost 10 years. How hard can it be? Turns out, MCP changes everything. After 92 production outages where my MCP server randomly failed with cryptic CORS errors that only happened in production but never locally, I finally figured out what was going on. And it's not what you think. Let me save you the three days of debugging I went through. Here's what was happening to me: localhost Access to XMLHttpRequest at 'https://my-mcp-server.com/mcp/tools/call' from origin 'https://chat.openai.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. But wait — I already had CORS configured I had cors.allowedOrigins " " in my Spring Boot config. What gives? So here's the thing about MCP that nobody tells you: MCP clients send OPTIONS preflight requests BEFORE authentication. Your authentication filter runs before the CORS filter, and it rejects the OPTIONS request because there's no API key. Boom — 403 before CORS headers get added. The browser sees a 403 without CORS headers and blocks the whole request. I had my filter order wrong. That's it. That's the whole problem that caused 92 outages. I know, I know — that sounds obvious now. But when you're in the middle of debugging "it works locally but not in production", it's really hard to see. Let me show you exactly how I fixed it. In Spring Boot and most Java app servers , filters run in the order you register them. What I had before wrong : AuthenticationFilter — checks for API key, rejects if missing/invalid CorsFilter — adds CORS headersWhat happens with OPTIONS preflight: The fix is stupidly simple: swap the order . What I have now correct : CorsFilter — adds CORS headers FIRST AuthenticationFilter — then check authenticationAnd you need one more thing: let OPTIONS requests pass through authentication . Preflight doesn't need authentication. The actual request after preflight will still need auth. Here's my complete CORS configuration that solved all my problems. You can copy-paste this directly into your project: python package io.github.kevinten10.papers.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; import java.util.Arrays; import java.util.List; @Configuration public class McpCorsConfig { @Bean @Order Ordered.HIGHEST PRECEDENCE // THIS IS THE MAGIC LINE — run BEFORE everything else public CorsFilter corsFilter { CorsConfiguration config = new CorsConfiguration ; // Allow all origins for MCP — any MCP client needs to connect config.setAllowedOriginPatterns List.of " " ; // MCP uses these methods — OPTIONS is critical for preflight config.setAllowedMethods Arrays.asList HttpMethod.GET.name , HttpMethod.POST.name , HttpMethod.OPTIONS.name ; // MCP sends these headers — allow all of them config.setAllowedHeaders List.of " " ; // Expose the SSE headers — some clients need this config.setExposedHeaders List.of "X-Accel-Buffering", "Cache-Control" ; // Allow credentials if you need them — I don't for public MCP servers config.setAllowCredentials false ; // How long browsers can cache preflight results — 1 hour is good config.setMaxAge 3600L ; UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource ; source.registerCorsConfiguration "/ ", config ; return new CorsFilter source ; } } And then you need to update your authentication filter to skip OPTIONS requests : @Component @Order Ordered.HIGHEST PRECEDENCE + 1 // Run AFTER CORS filter public class McpAuthFilter extends OncePerRequestFilter { @Override protected void doFilterInternal HttpServletRequest request, HttpServletResponse response, FilterChain filterChain throws ServletException, IOException { // SKIP PREFLIGHT — this is critical // Preflight doesn't have auth, and CORS already handled it if HttpMethod.OPTIONS.matches request.getMethod { filterChain.doFilter request, response ; return; } // Your normal authentication logic here... String apiKey = extractApiKey request ; if isValid apiKey { response.sendError HttpServletResponse.SC UNAUTHORIZED, "Invalid API key" ; return; } filterChain.doFilter request, response ; } private String extractApiKey HttpServletRequest request { // MCP clients put API key in different places: // 1. X-API-Key header String key = request.getHeader "X-API-Key" ; if key = null && key.isEmpty { return key; } // 2. Authorization: Bearer header key = request.getHeader "Authorization" ; if key = null && key.startsWith "Bearer " { return key.substring 7 ; } // 3. query parameter: ?api key=... key = request.getParameter "api key" ; if key = null && key.isEmpty { return key; } // 4. query parameter: ?apiKey=... key = request.getParameter "apiKey" ; if key = null && key.isEmpty { return key; } return null; } private boolean isValid String key { // your validation logic here return apiKeyStore.containsKey key ; } } That's it. 60 lines of code and that fixed all 92 of my CORS issues. Even after fixing filter order, I hit a couple more problems. Let me save you the pain: If you actually need credentials cookies, HTTP auth , you can't use " " for allowed origins. You have to explicitly list them. But for most public MCP servers that use API keys in headers, you don't need credentials, so setAllowCredentials false with " " works fine. If you're running behind Cloudflare, Nginx, or any reverse proxy, make sure your proxy isn't modifying the CORS headers that your app adds. For Nginx, make sure you have this in your location block: location /mcp/ { proxy pass http://localhost:8080; proxy set header Host $host; proxy set header X-Real-IP $remote addr; Don't change CORS headers that our app already sets proxy pass request headers on; } For Cloudflare, the default settings "just work" as long as your app is adding the headers correctly. I didn't need any special Page Rules or anything. Some bad proxies add duplicate Access-Control-Allow-Origin headers. Browsers hate this. The fix: let your app add the headers, don't let your proxy add them too. MCP always sends JSON, right? So your request has Content-Type: application/json . Guess what — any content-type other than application/x-www-form-urlencoded , multipart/form-data , or text/plain triggers a preflight OPTIONS request. That's just how CORS works. So you absolutely must handle OPTIONS correctly. There's no way around it for MCP. Let me be honest — this approach works great for me, but it might not work for you. Here's the breakdown: Three big things: MCP is different from regular REST APIs because of the preflight requirement. Regular APIs might not always trigger preflight, but MCP always does because we always send JSON. Local development lies to you — when you're on localhost, browsers handle CORS differently. Some browsers don't enforce CORS for localhost at all. That's why it works locally but fails in production. I can't tell you how many hours I wasted because of this. Filter order is everything — if your auth runs before CORS, you're gonna have a bad time. CORS has to come first. It doesn't matter that preflight doesn't need auth — it needs the CORS headers before auth can reject it. Honestly, I'm a little embarrassed that this took me three days to figure out. It seems so obvious now. But when you're in the middle of it, everything looks confusing. Hopefully this article saves you those three days. I've been building this MCP knowledge base for over a year now, and every week I find another "obvious" thing that breaks in production that nobody writes about. CORS was definitely one of the bigger surprises. Have you built an MCP server? Hit any weird CORS issues I didn't mention here? What's your go-to CORS configuration for MCP? Drop a comment below and let me know — I'm always curious to hear how other people are solving these problems. And if this saved you a few days of debugging, feel free to star the project on GitHub https://github.com/kevinten10/Papers — it helps other people find it