Understanding CORS Preflight Requests: Why Your MCP Server Needs OPTIONS Support
f you’ve ever built a web API or MCP (Model Context Protocol) server, you’ve probably encountered mysterious OPTIONS requests appearing in your server logs. Or worse, you’ve seen CORS errors in the browser console blocking your perfectly valid requests. Today, we’ll demystify CORS preflight requests and show you why they’re essential for secure web applications.
What is a CORS Preflight Request?
A CORS (Cross-Origin Resource Sharing) preflight request is an automatic HTTP OPTIONS request that browsers send before making certain cross-origin requests. Think of it as the browser asking the server for permission before sending the actual request.
The Preflight Handshake
Here’s what happens behind the scenes:Step 1: Browser Sends Preflight (OPTIONS)
OPTIONS /api/endpoint HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
The browser is asking: “Can I send a POST request with Authorization and Content-Type headers to your server?”Step 2: Server Responds to Preflight
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
The server replies: “Yes, you can! And I’ll remember this for 24 hours so you don’t need to ask again.”Step 3: Browser Sends Actual RequestOnly after the preflight succeeds does the browser send your actual API request.
When Do Browsers Send Preflight Requests?
Not all requests trigger a preflight. Browsers only send OPTIONS requests when the request is “complex” according to CORS rules.
Triggers That Cause Preflight
1. Custom Headers (Most Common)Any header that’s not in the safe list triggers a preflight:
// This triggers preflight
fetch(‘http://api.example.com/data’, {
headers: {
‘Authorization’: ‘Bearer token123’, // ❌ Triggers preflight
‘X-API-Key’: ‘secret’, // ❌ Triggers preflight
‘X-Custom-Header’: ‘value’ // ❌ Triggers preflight
}
});
2. Non-Simple HTTP Methods
// These trigger preflight
fetch(url, { method: ‘PUT’ }); // ❌ Preflight
fetch(url, { method: ‘DELETE’ }); // ❌ Preflight
fetch(url, { method: ‘PATCH’ }); // ❌ Preflight
// These DON’T trigger preflight
fetch(url, { method: ‘GET’ }); // ✅ Simple
fetch(url, { method: ‘HEAD’ }); // ✅ Simple
fetch(url, { method: ‘POST’ }); // ✅ Simple (with simple headers)
3. Custom Content-Types
// Triggers preflight
fetch(url, {
headers: {
‘Content-Type’: ‘application/json’ // Sometimes OK
}
});
// Safe content types (no preflight):
‘Content-Type’: ‘text/plain’
‘Content-Type’: ‘application/x-www-form-urlencoded’
‘Content-Type’: ‘multipart/form-data’
Why MCP Servers Always Get Preflight Requests
If you’re building an MCP server, you’ll almost certainly encounter preflight requests. Here’s why:MCP clients typically use authentication headers:
const response = await fetch(‘http://localhost:8500/mcp/server’, {
method: ‘POST’,
headers: {
‘Content-Type’: ‘application/json’,
‘Authorization’: ‘Basic dXNlcjpwYXNz’, // ⚠️ TRIGGERS PREFLIGHT!
‘Mcp-Protocol-Version’: ‘2024-11-05’
},
body: JSON.stringify({
jsonrpc: ‘2.0’,
method: ‘tools/call’,
params: { name: ‘calculateSum’, arguments: { a: 5, b: 3 } }
})
});
The Authorization header is not in the browser’s safe list, so a preflight is mandatory.
The Cost of Ignoring Preflight Requests
Without Proper OPTIONS Support:
Browser: OPTIONS /mcp/server
Server: 404 Not Found (or 405 Method Not Allowed)
Browser: ❌ CORS error! Blocking actual request.
JavaScript Console:
❌ Access to fetch at ‘http://localhost:8500/mcp/server’
has been blocked by CORS policy
Result: Your MCP client cannot connect. Your API is unusable from web browsers.
With Proper OPTIONS Support:
Browser: OPTIONS /mcp/server
Server: 200 OK + CORS headers
Browser: ✅ Preflight passed! Sending actual POST…
Server: 200 OK + {“result”: 8}
JavaScript: ✅ Success!
Result: Everything works smoothly.
Implementing Preflight Support: The Right Way
Here’s how to properly handle OPTIONS requests in your server:
1. Detect OPTIONS Requests
String method = request.getMethod();
if (“OPTIONS”.equals(method)) {
// Handle preflight
}
2. Set Required CORS Headers
// Tell browser what methods are allowed
response.setHeader(“Access-Control-Allow-Methods”, “POST, GET, OPTIONS”);
// Tell browser what headers are allowed
response.setHeader(“Access-Control-Allow-Headers”,
“Content-Type, Accept, Authorization, X-API-Key”);
// Cache preflight response for 24 hours
response.setHeader(“Access-Control-Max-Age”, “86400”);
3. Set the Origin Header
String requestOrigin = request.getHeader(“Origin”);
if (isCorsAllowed(requestOrigin)) {
response.setHeader(“Access-Control-Allow-Origin”, requestOrigin);
response.setHeader(“Vary”, “Origin”);
}
4. Add Security Headers
response.setHeader(“X-Content-Type-Options”, “nosniff”);
response.setHeader(“X-Frame-Options”, “DENY”);
response.setHeader(“Referrer-Policy”, “strict-origin-when-cross-origin”);
5. Return 200 OK
response.setStatus(200);
// No body needed for preflight response
Advanced: Origin-Specific CORS Control
Instead of allowing all origins with Access-Control-Allow-Origin: *, implement fine-grained control:
// Configure allowed origins
List<String> allowedOrigins = Arrays.asList(
“https://app.example.com”,
“https://admin.example.com”,
“*.example.com” // Wildcard for subdomains
);
// Check if request origin is allowed
boolean isCorsAllowed(String origin) {
if (allowedOrigins.contains(“*”)) {
return true; // Allow all
}
for (String allowed : allowedOrigins) {
if (allowed.equals(origin)) {
return true; // Exact match
}
// Handle wildcard patterns like “*.example.com”
if (allowed.startsWith(“*”)) {
String pattern = allowed.substring(1);
if (origin.endsWith(pattern)) {
return true;
}
}
}
return false;
}
Benefits:
- Restricts access to trusted domains only
- Prevents unauthorized cross-origin requests
- More secure than wildcard *
- Supports flexible patterns like *.example.com
Performance Optimization: Preflight Caching
The Access-Control-Max-Age header tells browsers to cache the preflight response:
response.setHeader(“Access-Control-Max-Age”, “86400”); // 24 hours
What this means:
- First request: OPTIONS + POST (2 requests)
- Subsequent requests for 24 hours: POST only (1 request)
- 50% reduction in requests for authenticated clients
Why CORS Preflight Exists: Security
Preflight requests are a security feature, not a bug. They protect users from malicious websites.
Without Preflight Protection:
- You log into bank.com
- You visit evil.com
- evil.com sends authenticated requests to bank.com using your cookies
- ❌ Your money is transferred to attacker
With Preflight Protection:
- You log into bank.com
- You visit evil.com
- Browser sends OPTIONS to bank.com
- bank.com rejects evil.com origin
- ✅ Browser blocks the actual request – your money is safe
Common Mistakes to Avoid
Mistake 1: Returning 405 for OPTIONS
// ❌ Wrong
if (!”POST”.equals(method)) {
response.sendError(405, “Method Not Allowed”);
}
This blocks preflight requests. Always allow OPTIONS.
Mistake 2: Not Including Required Headers
// ❌ Incomplete
response.setHeader(“Access-Control-Allow-Origin”, “*”);
// Missing Allow-Methods and Allow-Headers!
Preflight will fail without these headers.
Mistake 3: Checking Auth on OPTIONS Requests
// ❌ Wrong
if (“OPTIONS”.equals(method)) {
if (!isAuthenticated(request)) { // Don’t check auth on preflight!
return unauthorized();
}
}
Preflight requests don’t include auth tokens. Check auth only on actual requests.
Mistake 4: Validating Content-Type on OPTIONS
// ❌ Wrong
if (!”application/json”.equals(contentType)) {
return badRequest();
}
OPTIONS requests have no body, so Content-Type validation should be skipped.
Debugging CORS Issues
Check Browser DevTools
Open Network tab and look for:
- OPTIONS request (preflight) – should return 200
- POST request (actual) – should include your data
Common Error Messages
“has been blocked by CORS policy: Response to preflight request doesn’t pass access control check”
- Fix: Add missing Access-Control-Allow-Headers
“Method PUT is not allowed by Access-Control-Allow-Methods”
- Fix: Add PUT to Access-Control-Allow-Methods
“The ‘Access-Control-Allow-Origin’ header contains multiple values”
- Fix: Set origin header only once, not in multiple places
Testing Your Preflight Implementation
Use curl to test OPTIONS requests:
curl -X OPTIONS http://localhost:8500/api/endpoint \
-H “Origin: https://app.example.com” \
-H “Access-Control-Request-Method: POST” \
-H “Access-Control-Request-Headers: authorization, content-type” \
-v
Look for these headers in the response:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 86400
Conclusion
CORS preflight requests are an essential part of web security. While they might seem like an annoyance at first, they protect users from cross-origin attacks while enabling legitimate cross-origin communication.Key Takeaways:✅ Always handle OPTIONS requests in your API✅ Set all required CORS headers (Allow-Methods, Allow-Headers, Max-Age)✅ Use origin-specific CORS for better security✅ Cache preflight responses with Max-Age for better performance✅ Never check authentication on preflight requestsBy properly implementing preflight support, you ensure your MCP servers and APIs work seamlessly with web-based clients while maintaining strong security.
About the AuthorThis article is based on real-world implementation experience building MCP servers with ColdFusion and follows best practices from the CORS specification and modern API design patterns.Further Reading:
- MDN Web Docs: CORS
- W3C CORS Specification
- OWASP CORS Security Cheat Sheet
You can now copy this entire text and paste it directly into WordPress’s visual editor. It will format correctly with headers, code blocks, and lists!