cd /news/developer-tools/mcp-server-cors-the-preflight-proble… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-39453] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

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.

read6 min views1 publishedJun 25, 2026

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/invalidCorsFilter

β€” adds CORS headersWhat happens with OPTIONS preflight:

The fix is stupidly simple: swap the order.

What I have now (correct):

CorsFilter

β€” adds CORS headers FIRSTAuthenticationFilter

β€” 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:

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;

    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 β€” it helps other people find it!

── more in #developer-tools 4 stories Β· sorted by recency
── more on @spring boot 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/mcp-server-cors-the-…] indexed:0 read:6min 2026-06-25 Β· β€”