{"slug": "mcp-server-cors-the-preflight-problem-that-broke-my-mcp-server-92-times-and-how", "title": "MCP Server CORS: The Preflight Problem That Broke My MCP Server 92 Times And How I Fixed It For Good", "summary": "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.", "body_md": "Honestly, I thought I understood CORS. I've been building web apps for almost 10 years. How hard can it be?\n\nTurns out, MCP changes everything.\n\nAfter 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.\n\nLet me save you the three days of debugging I went through.\n\nHere's what was happening to me:\n\n`localhost`\n\n```\n  Access to XMLHttpRequest at 'https://my-mcp-server.com/mcp/tools/call' \n  from origin 'https://chat.openai.com' has been blocked by CORS policy: \n  Response to preflight request doesn't pass access control check: \n  No 'Access-Control-Allow-Origin' header is present on the requested resource.\n```\n\nBut wait — I already had CORS configured! I had `cors.allowedOrigins(\"*\")`\n\nin my Spring Boot config. What gives?\n\nSo here's the thing about MCP that nobody tells you:\n\n**MCP clients send OPTIONS preflight requests BEFORE authentication.**\n\nYour 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.\n\nI had my filter order wrong. That's it. That's the whole problem that caused 92 outages.\n\nI 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.\n\nLet me show you exactly how I fixed it.\n\nIn Spring Boot (and most Java app servers), filters run in the order you register them.\n\nWhat I had before (wrong):\n\n`AuthenticationFilter`\n\n— checks for API key, rejects if missing/invalid`CorsFilter`\n\n— adds CORS headersWhat happens with OPTIONS preflight:\n\nThe fix is stupidly simple: **swap the order**.\n\nWhat I have now (correct):\n\n`CorsFilter`\n\n— adds CORS headers FIRST`AuthenticationFilter`\n\n— then check authenticationAnd you need one more thing: **let OPTIONS requests pass through authentication**.\n\nPreflight doesn't need authentication. The actual request after preflight will still need auth.\n\nHere's my complete CORS configuration that solved all my problems. You can copy-paste this directly into your project:\n\n``` python\npackage io.github.kevinten10.papers.config;\n\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.core.Ordered;\nimport org.springframework.core.annotation.Order;\nimport org.springframework.http.HttpMethod;\nimport org.springframework.web.cors.CorsConfiguration;\nimport org.springframework.web.cors.CorsConfigurationSource;\nimport org.springframework.web.cors.UrlBasedCorsConfigurationSource;\nimport org.springframework.web.filter.CorsFilter;\n\nimport java.util.Arrays;\nimport java.util.List;\n\n@Configuration\npublic class McpCorsConfig {\n\n    @Bean\n    @Order(Ordered.HIGHEST_PRECEDENCE) // THIS IS THE MAGIC LINE — run BEFORE everything else\n    public CorsFilter corsFilter() {\n        CorsConfiguration config = new CorsConfiguration();\n\n        // Allow all origins for MCP — any MCP client needs to connect\n        config.setAllowedOriginPatterns(List.of(\"*\"));\n\n        // MCP uses these methods — OPTIONS is critical for preflight\n        config.setAllowedMethods(Arrays.asList(\n            HttpMethod.GET.name(),\n            HttpMethod.POST.name(), \n            HttpMethod.OPTIONS.name()\n        ));\n\n        // MCP sends these headers — allow all of them\n        config.setAllowedHeaders(List.of(\"*\"));\n\n        // Expose the SSE headers — some clients need this\n        config.setExposedHeaders(List.of(\n            \"X-Accel-Buffering\", \n            \"Cache-Control\"\n        ));\n\n        // Allow credentials if you need them — I don't for public MCP servers\n        config.setAllowCredentials(false);\n\n        // How long browsers can cache preflight results — 1 hour is good\n        config.setMaxAge(3600L);\n\n        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();\n        source.registerCorsConfiguration(\"/**\", config);\n        return new CorsFilter(source);\n    }\n}\n```\n\nAnd then you need to update your authentication filter to **skip OPTIONS requests**:\n\n```\n@Component\n@Order(Ordered.HIGHEST_PRECEDENCE + 1) // Run AFTER CORS filter\npublic class McpAuthFilter extends OncePerRequestFilter {\n\n    @Override\n    protected void doFilterInternal(\n            HttpServletRequest request, \n            HttpServletResponse response, \n            FilterChain filterChain\n    ) throws ServletException, IOException {\n\n        // SKIP PREFLIGHT — this is critical!\n        // Preflight doesn't have auth, and CORS already handled it\n        if (HttpMethod.OPTIONS.matches(request.getMethod())) {\n            filterChain.doFilter(request, response);\n            return;\n        }\n\n        // Your normal authentication logic here...\n        String apiKey = extractApiKey(request);\n        if (!isValid(apiKey)) {\n            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, \"Invalid API key\");\n            return;\n        }\n\n        filterChain.doFilter(request, response);\n    }\n\n    private String extractApiKey(HttpServletRequest request) {\n        // MCP clients put API key in different places:\n        // 1. X-API-Key header\n        String key = request.getHeader(\"X-API-Key\");\n        if (key != null && !key.isEmpty()) {\n            return key;\n        }\n\n        // 2. Authorization: Bearer header\n        key = request.getHeader(\"Authorization\");\n        if (key != null && key.startsWith(\"Bearer \")) {\n            return key.substring(7);\n        }\n\n        // 3. query parameter: ?api_key=...\n        key = request.getParameter(\"api_key\");\n        if (key != null && !key.isEmpty()) {\n            return key;\n        }\n\n        // 4. query parameter: ?apiKey=...\n        key = request.getParameter(\"apiKey\");\n        if (key != null && !key.isEmpty()) {\n            return key;\n        }\n\n        return null;\n    }\n\n    private boolean isValid(String key) {\n        // your validation logic here\n        return apiKeyStore.containsKey(key);\n    }\n}\n```\n\nThat's it. 60 lines of code and that fixed all 92 of my CORS issues.\n\nEven after fixing filter order, I hit a couple more problems. Let me save you the pain:\n\nIf you actually need credentials (cookies, HTTP auth), you can't use `\"*\"`\n\nfor 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)`\n\nwith `\"*\"`\n\nworks fine.\n\nIf you're running behind Cloudflare, Nginx, or any reverse proxy, make sure your proxy isn't modifying the CORS headers that your app adds.\n\nFor Nginx, make sure you have this in your location block:\n\n```\nlocation /mcp/ {\n    proxy_pass http://localhost:8080;\n    proxy_set_header Host $host;\n    proxy_set_header X-Real-IP $remote_addr;\n\n    # Don't change CORS headers that our app already sets\n    proxy_pass_request_headers on;\n}\n```\n\nFor 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.\n\nSome bad proxies add duplicate `Access-Control-Allow-Origin`\n\nheaders. Browsers hate this. The fix: let your app add the headers, don't let your proxy add them too.\n\nMCP always sends JSON, right? So your request has `Content-Type: application/json`\n\n. Guess what — any content-type other than `application/x-www-form-urlencoded`\n\n, `multipart/form-data`\n\n, or `text/plain`\n\ntriggers 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.\n\nLet me be honest — this approach works great for me, but it might not work for you. Here's the breakdown:\n\n`*`\n\nThree big things:\n\n**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.\n\n**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.\n\n**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.\n\nHonestly, 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.\n\nI'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.\n\nHave 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.\n\nAnd 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!", "url": "https://wpnews.pro/news/mcp-server-cors-the-preflight-problem-that-broke-my-mcp-server-92-times-and-how", "canonical_source": "https://dev.to/kevinten10/mcp-server-cors-the-preflight-problem-that-broke-my-mcp-server-92-times-and-how-i-fixed-it-for-good-2opf", "published_at": "2026-06-25 14:34:52+00:00", "updated_at": "2026-06-25 14:43:25.437079+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["Spring Boot", "MCP", "CORS", "OpenAI"], "alternates": {"html": "https://wpnews.pro/news/mcp-server-cors-the-preflight-problem-that-broke-my-mcp-server-92-times-and-how", "markdown": "https://wpnews.pro/news/mcp-server-cors-the-preflight-problem-that-broke-my-mcp-server-92-times-and-how.md", "text": "https://wpnews.pro/news/mcp-server-cors-the-preflight-problem-that-broke-my-mcp-server-92-times-and-how.txt", "jsonld": "https://wpnews.pro/news/mcp-server-cors-the-preflight-problem-that-broke-my-mcp-server-92-times-and-how.jsonld"}}