DEV Community

KevinTen
KevinTen

Posted on

MCP Server CORS: The Preflight Problem That Broke My MCP Server 92 Times And How I Fixed It For Good

MCP Server CORS: The Preflight Problem That Broke My MCP Server 92 Times And How I Fixed It For Good

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.


The Problem: CORS Preflight Fails On Authentication

Here's what was happening to me:

  • Everything worked perfectly fine locally running on localhost
  • As soon as I deployed to production with HTTPS and Cloudflare/Nginx, random preflight OPTIONS requests started failing with 403
  • The weird part? It only failed for some requests, not all. And when it failed, it wasn't a network error — it was a CORS error in the browser console that said:
  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.
Enter fullscreen mode Exit fullscreen mode

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.


The Root Cause: Filter Order Matters More Than You Think

In Spring Boot (and most Java app servers), filters run in the order you register them.

What I had before (wrong):

  1. AuthenticationFilter — checks for API key, rejects if missing/invalid
  2. CorsFilter — adds CORS headers

What happens with OPTIONS preflight:

  • OPTIONS request comes in
  • No API key in the request (preflight doesn't send credentials by default)
  • AuthenticationFilter rejects with 403
  • CorsFilter never gets to run
  • No CORS headers in the 403 response
  • Browser blocks the entire request 💥

The fix is stupidly simple: swap the order.

What I have now (correct):

  1. CorsFilter — adds CORS headers FIRST
  2. AuthenticationFilter — then check authentication

And 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.


The Complete Working Code (Spring Boot + Java)

Here's my complete CORS configuration that solved all my problems. You can copy-paste this directly into your project:

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

That's it. 60 lines of code and that fixed all 92 of my CORS issues.


But Wait — There's More! Other CORS Gotchas I Hit

Even after fixing filter order, I hit a couple more problems. Let me save you the pain:

Gotcha 1: "*" Doesn't Work With allowCredentials

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.

Gotcha 2: Some Proxies Strip CORS Headers

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Gotcha 3: Multiple Origin Headers

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.

Gotcha 4: Content-Type Preflight

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.


Pros & Cons: Is This Approach Right For You?

Let me be honest — this approach works great for me, but it might not work for you. Here's the breakdown:

✅ Pros

  1. Dead simple — uses standard Spring Boot/Servlet features, no custom code
  2. All MCP clients work — every client I've tested (Claude Desktop, OpenAI GPT, whatever) connects perfectly now
  3. Zero outage rate — after deploying this fix, I've had zero CORS-related issues in production
  4. Standard-compliant — follows the actual CORS spec, doesn't hack around it
  5. Backward compatible — existing clients don't need any changes

❌ Cons

  1. Allows any origin — if you're building a private MCP server that only specific domains should access, you need to restrict allowed origins instead of using *
  2. Doesn't solve origin validation — if you need to restrict which origins can connect, you still have to implement that yourself
  3. Preflight for every request — but that's just how CORS works for JSON requests, nothing you can do about it

So What Did I Actually Learn From All This?

Three big things:

  1. 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.

  2. 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.

  3. 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.


What's Your CORS Story?

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 — it helps other people find it!

Top comments (1)

Collapse
 
topstar_ai profile image
Luis

This post is essentially about a classic MCP deployment issue that looks simple but keeps breaking real servers: CORS preflight + OPTIONS handling.

Tight summary

The “92 times broken MCP server” problem usually comes down to one core mistake:

The MCP server correctly handles real requests, but fails the browser’s preflight (OPTIONS) request, so everything gets blocked before execution.

What’s actually happening

When a browser calls an MCP HTTP endpoint, it often triggers a CORS preflight:

Browser sends an OPTIONS request first
Server must respond with correct headers:
Access-Control-Allow-Origin
Access-Control-Allow-Methods
Access-Control-Allow-Headers
If ANY of these are missing → the real MCP request is never sent
Why MCP servers hit this so often

MCP servers amplify the issue because they typically:

expose tool-like endpoints (POST-heavy APIs)
use custom headers for auth / context
run behind frameworks that don’t auto-handle OPTIONS
are tested in non-browser tools first (so CORS bug stays hidden)

So everything “works in curl/Postman” but fails in browser integration.

The real root cause (what the post is pointing at)

Most broken MCP setups miss one of these:

No explicit OPTIONS route handler
CORS middleware not applied globally (only on POST routes)
Missing Access-Control-Allow-Headers for MCP-specific headers
Incorrect wildcard usage with credentials (* + cookies = failure)
Proxy layers stripping CORS headers on preflight response
The fix pattern (what actually works)

In production MCP servers:

Handle OPTIONS explicitly (or via middleware)
Apply CORS before all routes
Mirror request headers safely (not blindly wildcard everything)
Ensure preflight responses return 204/200 with full headers
Validate behind reverse proxies (Nginx/Cloudflare often removes headers silently)
Bigger takeaway

This isn’t really a “CORS bug” — it’s a protocol boundary mismatch problem:

MCP servers are being designed like backend APIs, but consumed like browser-first HTTP services.

That mismatch is why this class of bug keeps recurring in agent + MCP systems.

If you want, I can turn this into a production-grade MCP CORS template (Express / FastAPI / Node / Cloudflare Worker) that eliminates this entire failure mode permanently.