The OPTIONS Dance
We've established what happens during a preflight: the browser sends an OPTIONS request, the server responds with CORS headers, and the browser decides whether to proceed. Now let's get into the weeds of the OPTIONS method itself — what it actually is, how to handle it on the server, why so many frameworks get it wrong by default, and how to debug it when things go sideways.
OPTIONS Is Not a CORS Invention
The OPTIONS HTTP method predates CORS by over a decade. It's defined in RFC 7231, Section 4.3.7 as a general-purpose method for asking a server about the communication options available for a given resource. The original intent was introspection: "What methods does this endpoint support? What content types can you handle?"
OPTIONS /users HTTP/1.1
Host: api.example.com
A conforming server might respond:
HTTP/1.1 200 OK
Allow: GET, POST, OPTIONS
Content-Length: 0
The Allow header lists the methods this endpoint supports. That's the pre-CORS use of OPTIONS — simple capability discovery.
In practice, almost nobody used OPTIONS for this purpose. It was one of those parts of HTTP that existed on paper but rarely showed up in the wild. REST API documentation, Swagger/OpenAPI specs, and simple trial-and-error filled the gap that OPTIONS was supposed to address.
And then CORS came along and gave OPTIONS a second career.
In Practice, OPTIONS Means CORS
Let me be precise about this: technically, OPTIONS has a purpose beyond CORS. In reality, if you see an OPTIONS request in your server logs, there is approximately a 99% chance it's a CORS preflight. The remaining 1% is someone using a REST client's "discover capabilities" feature, or an HTTP compliance test suite, or someone who read the RFC and is actually using OPTIONS as intended (bless their heart).
This has an interesting side effect: many developers' first encounter with the OPTIONS method is a CORS error. They've never seen it before, they don't know what it does, and suddenly their server logs are full of mysterious OPTIONS requests that seem to come from nowhere. "I'm sending a PUT request. Why is my server getting an OPTIONS request?"
Now you know why.
Identifying a CORS Preflight vs. a Regular OPTIONS Request
How do you tell whether an OPTIONS request is a CORS preflight or a "regular" OPTIONS request? Check for these two request headers:
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
If the OPTIONS request includes Access-Control-Request-Method, it's a CORS preflight. Period. This header is only added by browsers as part of the CORS protocol. Regular OPTIONS requests (the RFC 7231 kind) don't include it.
A comparison:
Regular OPTIONS request (rare):
OPTIONS /users HTTP/1.1
Host: api.example.com
CORS preflight (common):
OPTIONS /users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
The presence of Origin, Access-Control-Request-Method, and optionally Access-Control-Request-Headers is the signature of a CORS preflight. In your server-side code, you can use this to distinguish the two cases — though in most applications, you'll just handle all OPTIONS requests the same way.
Why Frameworks Don't Handle OPTIONS by Default
Here's where things get annoying. Most web frameworks are designed around the idea that you define routes for the methods you want to handle:
// Express
app.get('/users', listUsers);
app.post('/users', createUser);
app.put('/users/:id', updateUser);
app.delete('/users/:id', deleteUser);
# Flask
@app.route('/users', methods=['GET', 'POST'])
def users():
...
@app.route('/users/<id>', methods=['PUT', 'DELETE'])
def user(id):
...
// Go net/http
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("PUT /users/{id}", updateUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)
Notice what's missing? Nobody registered a handler for OPTIONS. And why would they? You're building an API, not writing a CORS middleware from scratch. But the browser doesn't care about your development workflow — it needs OPTIONS to be handled, with the right headers, for every route that receives cross-origin requests.
What happens when a preflight OPTIONS request hits a route with no OPTIONS handler depends on the framework:
| Framework | Default behavior for unhandled OPTIONS |
|---|---|
| Express (Node.js) | 404 if no route matches; some versions auto-respond with Allow header |
| Koa (Node.js) | 405 Method Not Allowed |
| Flask (Python) | 405 Method Not Allowed |
| Django (Python) | 405 Method Not Allowed |
| Gin (Go) | 404 or 405, depending on configuration |
| Actix-web (Rust) | 405 Method Not Allowed |
| Spring Boot (Java) | Depends on configuration; often 403 |
| Ruby on Rails | 404 if no route matches |
| ASP.NET Core | 405 Method Not Allowed |
In every case, the response won't include Access-Control-Allow-Origin or any of the other CORS headers. The preflight fails. Your actual request is never sent. You open a Stack Overflow tab.
The Express Example, In Detail
Let's walk through the most common version of this problem. You're building an API with Express:
const express = require('express');
const app = express();
app.use(express.json());
app.get('/api/users', (req, res) => {
res.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
]);
});
app.post('/api/users', (req, res) => {
const user = { id: 3, name: req.body.name };
res.status(201).json(user);
});
app.listen(3001, () => console.log('API running on port 3001'));
You test with curl:
# GET works
curl http://localhost:3001/api/users
# [{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]
# POST works
curl -X POST http://localhost:3001/api/users \
-H "Content-Type: application/json" \
-d '{"name": "Charlie"}'
# {"id":3,"name":"Charlie"}
Everything looks great. You deploy this to https://api.example.com and your frontend at https://app.example.com tries to use it:
// On https://app.example.com
const res = await fetch('https://api.example.com/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Charlie' })
});
This fails. The Content-Type: application/json triggers a preflight. The browser sends OPTIONS /api/users. Express has no handler for OPTIONS /api/users. Express returns... well, it depends on the version and configuration, but it won't include CORS headers. The preflight fails.
Meanwhile, the GET request from a browser without custom headers might work as a simple request — but only if the server includes Access-Control-Allow-Origin in the GET response. Which it doesn't, because we haven't configured CORS at all. So everything fails.
The Fix: cors Middleware
npm install cors
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors({
origin: 'https://app.example.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
// ... route handlers unchanged
The cors middleware does several things:
- For all requests: Adds
Access-Control-Allow-Originto every response - For OPTIONS requests: Intercepts them, responds with all the
Access-Control-Allow-*headers, and ends the response (your route handlers never see the OPTIONS request) - Adds
Vary: Originto responses so caches behave correctly
You can verify it works:
# Simulate a preflight
curl -v -X OPTIONS https://api.example.com/api/users \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type"
# Expected response headers:
# Access-Control-Allow-Origin: https://app.example.com
# Access-Control-Allow-Methods: GET,POST,PUT,DELETE
# Access-Control-Allow-Headers: Content-Type,Authorization
# Access-Control-Max-Age: (some value, or absent)
The Manual Fix (No Middleware)
If you don't want a dependency, you can handle it yourself. This is educational even if you end up using the middleware:
app.use((req, res, next) => {
// Set CORS headers for all responses
res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com');
res.setHeader('Vary', 'Origin');
// Handle preflight
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '86400');
res.status(204).end();
return;
}
next();
});
This middleware runs before your route handlers. For OPTIONS requests, it responds immediately with the CORS headers and a 204 status. For all other requests, it adds the Access-Control-Allow-Origin header and passes control to the next middleware.
The 204 vs 200 Debate
You'll see both 204 No Content and 200 OK used for OPTIONS responses. Which is correct?
204 No Content is the more semantically accurate choice. An OPTIONS response to a preflight has no body — it's pure metadata in the headers. Status 204 means "the request succeeded and there's intentionally no content." The Fetch spec doesn't mandate a specific success status code; it just checks for a successful response (status 200-299) and the presence of the right headers.
200 OK works fine too. The browser doesn't care which 2xx status you use. Some developers prefer 200 because it's more familiar and some HTTP clients handle it more predictably.
What definitely doesn't work:
301,302,307,308— Redirects. Some browsers follow them for preflights, others don't. Don't redirect preflights.401,403— Authentication errors. The preflight shouldn't require authentication. If your auth middleware runs before your CORS middleware and rejects the OPTIONS request because it has noAuthorizationheader... that's a bug. The preflight asking to send anAuthorizationheader cannot itself carry anAuthorizationheader.404,405— Route not found or method not allowed. This is what you get when your framework doesn't handle OPTIONS.
# Quick test: what status does your server return for OPTIONS?
curl -o /dev/null -s -w "%{http_code}" -X OPTIONS \
https://api.example.com/api/users \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type"
# 204 or 200 = good
# 404 or 405 = your server doesn't handle OPTIONS
# 401 or 403 = your auth middleware is intercepting the preflight
The 401/403 case deserves special attention because it's incredibly common. Your authentication middleware checks every request for a valid token. The preflight OPTIONS request doesn't have a token (it can't — that's the whole point of asking). The auth middleware rejects it. The preflight fails. You stare at your screen wondering why a request that works in Postman doesn't work in the browser.
The fix: make sure your CORS handling runs before your authentication middleware, and that it short-circuits OPTIONS requests before they hit the auth layer.
Why Your Server Logs Show Mysterious OPTIONS Requests
If you've enabled request logging on your API server, you'll start seeing entries like:
[2026-03-21T14:22:01Z] OPTIONS /api/users 204 0ms
[2026-03-21T14:22:01Z] POST /api/users 201 45ms
[2026-03-21T14:22:03Z] OPTIONS /api/users/42 204 0ms
[2026-03-21T14:22:03Z] PUT /api/users/42 200 32ms
[2026-03-21T14:22:05Z] OPTIONS /api/users/42 204 0ms
[2026-03-21T14:22:05Z] DELETE /api/users/42 200 28ms
Every "real" request is paired with an OPTIONS request. Your request volume effectively doubles (at least until preflight caching kicks in). This is normal. This is CORS working as designed. Some teams configure their logging to filter out or de-emphasize OPTIONS requests to reduce noise:
// Express logging middleware that skips OPTIONS
app.use((req, res, next) => {
if (req.method !== 'OPTIONS') {
console.log(`${req.method} ${req.path} - ${res.statusCode}`);
}
next();
});
Whether you should filter them from logs depends on your debugging needs. During development, seeing the OPTIONS requests helps you verify that CORS is configured correctly. In production, you might want to reduce log volume.
Seeing Preflights in Browser DevTools
Chrome
- Open DevTools (F12 or Cmd+Option+I on Mac)
- Go to the Network tab
- Trigger the action that makes the API call
- You'll see two requests for each preflight-requiring call
To make preflights easier to spot:
- Right-click the column headers and enable Method if it's not already visible
- In the filter bar, type
method:OPTIONSto show only preflights - Or use the general filter to search by URL pattern to see both the OPTIONS and the actual request together
When you click on an OPTIONS request, the Headers tab shows:
- Request Headers:
Origin,Access-Control-Request-Method,Access-Control-Request-Headers - Response Headers:
Access-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Allow-Headers,Access-Control-Max-Age
If the preflight succeeded, you'll see the actual request immediately after the OPTIONS in the network log. If it failed, you'll see only the OPTIONS, and the Console tab will have the CORS error.
A subtle Chrome behavior: Chrome sometimes hides preflight requests in the Network tab by default. Look for a checkbox or filter labeled "Other" in the request type filters (XHR, JS, CSS, etc.). Preflights are classified as "Other" because they're not XHR/fetch requests from your code — they're browser-initiated.
Firefox
Firefox is a bit more developer-friendly for CORS debugging:
- Open DevTools (F12)
- Go to the Network tab
- Firefox labels preflight requests with a small "CORS" badge
- Click on a request, and the headers panel explicitly groups CORS-related headers together
- Firefox's Console messages often include a direct link to the relevant MDN documentation page
Firefox also provides a CORS filter in the Network tab's type filters, which is dedicated to showing only CORS-related requests. This is genuinely useful when debugging complex pages.
A Debugging Workflow
When a CORS error appears, here's the systematic approach:
# Step 1: Identify what the browser is trying to send
# Look at the Console error — it tells you the URL and often the issue
# Step 2: Simulate the preflight with curl
curl -v -X OPTIONS https://api.example.com/your/endpoint \
-H "Origin: https://your-frontend.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: content-type, authorization"
# Step 3: Check the response headers
# Do you see Access-Control-Allow-Origin? Access-Control-Allow-Methods?
# If not, the server isn't handling the preflight
# Step 4: Check your server logs
# Did the OPTIONS request arrive? What status did it return?
# Step 5: Check middleware ordering
# Is CORS middleware running before auth middleware?
# Is CORS middleware running before your router?
Common Framework Configurations for Auto-Handling OPTIONS
Since "handle OPTIONS correctly" is a universal requirement for APIs that serve browsers, every serious framework has a solution. Here's a quick reference:
Express (Node.js)
const cors = require('cors');
app.use(cors({
origin: 'https://app.example.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400
}));
Fastify (Node.js)
await fastify.register(require('@fastify/cors'), {
origin: 'https://app.example.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400
});
Flask (Python)
from flask_cors import CORS
app = Flask(__name__)
CORS(app, origins=['https://app.example.com'],
methods=['GET', 'POST', 'PUT', 'DELETE'],
allow_headers=['Content-Type', 'Authorization'],
max_age=86400)
Django (Python)
# settings.py
INSTALLED_APPS = [
...
'corsheaders',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # must be high in the list
'django.middleware.common.CommonMiddleware',
...
]
CORS_ALLOWED_ORIGINS = ['https://app.example.com']
CORS_ALLOW_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
CORS_ALLOW_HEADERS = ['content-type', 'authorization']
CORS_PREFLIGHT_MAX_AGE = 86400
Gin (Go)
import "github.com/gin-contrib/cors"
r := gin.Default()
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"https://app.example.com"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowHeaders: []string{"Content-Type", "Authorization"},
MaxAge: 24 * time.Hour,
}))
Actix-web (Rust)
#![allow(unused)] fn main() { use actix_cors::Cors; let cors = Cors::default() .allowed_origin("https://app.example.com") .allowed_methods(vec!["GET", "POST", "PUT", "DELETE"]) .allowed_headers(vec!["Content-Type", "Authorization"]) .max_age(86400); App::new() .wrap(cors) // ... routes }
Spring Boot (Java)
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Content-Type", "Authorization")
.maxAge(86400);
}
}
ASP.NET Core (C#)
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowFrontend", policy =>
{
policy.WithOrigins("https://app.example.com")
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Content-Type", "Authorization")
.SetPreflightMaxAge(TimeSpan.FromDays(1));
});
});
// In the middleware pipeline (order matters!)
app.UseCors("AllowFrontend");
In every case, notice the pattern: the CORS middleware/configuration is applied globally and early in the middleware pipeline. It handles OPTIONS requests automatically, adds the right headers to all responses, and ensures that your route handlers don't need to know anything about CORS.
One More Thing: OPTIONS and Caching Proxies
If you have a reverse proxy or CDN in front of your API (Nginx, Cloudflare, AWS CloudFront, etc.), there's an additional wrinkle. The proxy might cache the OPTIONS response and serve it to all clients, regardless of origin. This is why Vary: Origin matters — it tells the cache that different origins may get different responses.
Without Vary: Origin:
- Browser A (origin
https://app-a.com) sends a preflight - Server responds with
Access-Control-Allow-Origin: https://app-a.com - CDN caches this response
- Browser B (origin
https://app-b.com) sends a preflight - CDN serves the cached response:
Access-Control-Allow-Origin: https://app-a.com - Browser B's preflight fails because the origin doesn't match
With Vary: Origin, the CDN knows to cache separate responses for each unique Origin header value. Most CORS middleware libraries include Vary: Origin automatically, but if you're configuring CORS at the proxy level (Nginx, for example), you'll need to add it yourself.
Summary
The OPTIONS method is the mechanism that makes CORS preflights work. It's an HTTP method that predates CORS but found its true calling as the preflight request type. The key points:
- OPTIONS +
Access-Control-Request-Method= CORS preflight. If you see both, it's CORS. - Your server must handle OPTIONS for every route that receives cross-origin requests. Use framework middleware to do this automatically.
- CORS middleware must run before auth middleware. A preflight cannot carry credentials. If auth rejects the OPTIONS, CORS fails.
- Respond to preflights with 204 or 200, appropriate headers, and no body.
- Include
Vary: Originto keep caches honest. - Use DevTools to inspect preflights. The Network tab shows the OPTIONS/actual request pair. The Console shows specific error messages about what went wrong.
If your OPTIONS handling is correct, everything else in CORS becomes straightforward. If it's wrong, nothing else matters. Get the OPTIONS dance right first.