Introduction

Welcome back! In our previous lesson, we explored the basics of Cross-Origin Resource Sharing (CORS) and its significance in web development. Now, we're going to dive deeper into a crucial aspect of CORS: preflight requests. These requests play a vital role in ensuring secure cross-origin communication. Think of them as a security check before allowing access to resources. By the end of this lesson, you'll understand what preflight requests are, when they occur, and how to handle them effectively in your Python REST API using FastAPI. Let's get started! 🚀

Understanding Preflight Requests

Preflight requests are a part of the CORS mechanism that browsers use to determine if a cross-origin request is safe to send. They are triggered when a request uses methods other than simple methods like GET, HEAD, or POST (with certain content types), or when custom headers are included. To be more specific, POST requests are only considered "simple" if they use one of the following content types: application/x-www-form-urlencoded, multipart/form-data, or text/plain. If a POST request uses application/json—which is common in modern APIs—it will trigger a preflight request. This is an important detail that explains why many seemingly normal API requests may involve a preflight check.

Here's how preflight requests work:

  • When you make a "non-simple" cross-origin request, the browser first sends an OPTIONS request to the server
  • This preflight request checks if the actual request is allowed based on origin, method, and headers
  • The server responds with specific CORS headers indicating what's permitted
  • Only if the preflight is successful will the browser send the actual request

Imagine you're entering a secure building; a preflight request is like the security guard checking your credentials before letting you in. Without proper handling of these preflight requests, certain cross-origin requests will be blocked by browsers.

Why Preflight Requests Matter

To understand why we need to handle preflight requests properly, let's look at a common scenario:

Suppose your frontend (running on http://localhost:3000) needs to make a PUT request to your API (running on http://localhost:8000) with a custom Authorization header. Before sending the actual PUT request, the browser will automatically send an OPTIONS request to check if:

  1. The server allows requests from http://localhost:3000 (origin)
  2. The server allows PUT methods
  3. The server accepts the Authorization header

If your server doesn't properly respond to this OPTIONS request with the appropriate CORS headers, the browser will block the actual PUT request, and you'll see an error like:

Access to fetch at 'http://localhost:8000/api/resource' from origin 'http://localhost:3000' 
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.
Setting Up Preflight Handling: Basic Configuration

Let's implement proper preflight request handling in our Python REST API using FastAPI:

from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from typing import List, Optional

app = FastAPI()

class CustomCORSMiddleware(BaseHTTPMiddleware):
    def __init__(self, app, 
                 allowed_origins: List[str] = None,
                 allowed_methods: List[str] = None,
                 allowed_headers: List[str] = None,
                 allow_credentials: bool = True,
                 max_age: int = 600):
        super().__init__(app)
        self.allowed_origins = allowed_origins or ['http://localhost:3000']
        self.allowed_methods = allowed_methods or ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']
        self.allowed_headers = allowed_headers or ['Content-Type', 'Authorization', 'X-Requested-With']
        self.allow_credentials = allow_credentials
        self.max_age = max_age

    def _is_origin_allowed(self, origin: str) -> bool:
        return origin in self.allowed_origins
    
    async def dispatch(self, request: Request, call_next):
        origin = request.headers.get('origin')
        method = request.method
        
        # Handle preflight requests (OPTIONS)
        if method == 'OPTIONS' and origin:
            if self._is_origin_allowed(origin):
                response = Response(status_code=204)  # No Content for preflight
                response.headers['Access-Control-Allow-Origin'] = origin
                response.headers['Access-Control-Allow-Methods'] = ', '.join(self.allowed_methods)
                response.headers['Access-Control-Allow-Headers'] = ', '.join(self.allowed_headers)
                response.headers['Access-Control-Max-Age'] = str(self.max_age)
                if self.allow_credentials:
                    response.headers['Access-Control-Allow-Credentials'] = 'true'
                return response
        
        # Process the actual request
        response = await call_next(request)
        
        # Add CORS headers to actual responses
        if origin and self._is_origin_allowed(origin):
            response.headers['Access-Control-Allow-Origin'] = origin
            if self.allow_credentials:
                response.headers['Access-Control-Allow-Credentials'] = 'true'
        
        return response

# Apply CORS middleware to handle preflight requests
app.add_middleware(CustomCORSMiddleware, 
                  allowed_origins=['http://localhost:3000'],
                  allowed_methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
                  allowed_headers=['Content-Type', 'Authorization', 'X-Requested-With'],
                  allow_credentials=True,
                  max_age=600)  # Cache preflight response for 10 minutes

# Your routes here
@app.get('/api/resources')
async def get_resources():
    return {'message': 'This is a GET endpoint'}

@app.put('/api/resources/{resource_id}')
async def update_resource(resource_id: int):
    return {'message': 'This is a PUT endpoint'}

if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app, host='0.0.0.0', port=8000)

When a browser sends a preflight request to this server, the CORS middleware automatically responds with the following headers:

Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,PATCH,OPTIONS
Access-Control-Allow-Headers: Content-Type,Authorization,X-Requested-With
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600

The browser uses these headers to determine if the actual request is allowed. The max_age parameter tells browsers how long (in seconds) they can cache the preflight response, reducing the number of preflight requests and improving performance.

Note: However, not all browsers respect the max_age setting equally. For example, some versions of Safari have been known to ignore this header, resulting in more frequent preflight requests than expected.

Setting Up Preflight Handling: Route-Specific CORS Policies

Sometimes, you might want different CORS policies for different routes. Here's how to create middleware for specific route types:

from fastapi import FastAPI, APIRouter, Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response, JSONResponse
from typing import List, Dict, Optional

def create_preflight_handler(allowed_origins: List[str], 
                           allowed_methods: List[str] = None,
                           allowed_headers: List[str] = None,
                           max_age: int = 600):
    """Create a CORS middleware with specific configuration"""
    
    if allowed_methods is None:
        allowed_methods = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
    if allowed_headers is None:
        allowed_headers = ['Content-Type', 'Authorization']
    
    class SpecificCORSMiddleware(BaseHTTPMiddleware):
        def __init__(self, app):
            super().__init__(app)
        
        def _is_origin_allowed(self, origin: str) -> bool:
            return origin in allowed_origins
        
        async def dispatch(self, request: Request, call_next):
            origin = request.headers.get('origin')
            method = request.method
            
            # Handle preflight requests (OPTIONS)
            if method == 'OPTIONS' and origin:
                if self._is_origin_allowed(origin):
                    response = Response(status_code=204)
                    response.headers['Access-Control-Allow-Origin'] = origin
                    response.headers['Access-Control-Allow-Methods'] = ', '.join(allowed_methods)
                    response.headers['Access-Control-Allow-Headers'] = ', '.join(allowed_headers)
                    response.headers['Access-Control-Max-Age'] = str(max_age)
                    response.headers['Access-Control-Allow-Credentials'] = 'true'
                    return response
                else:
                    return JSONResponse(
                        content={'error': f'Origin {origin} not allowed'},
                        status_code=403
                    )
            
            # Process the actual request
            response = await call_next(request)
            
            # Add CORS headers to actual responses
            if origin and self._is_origin_allowed(origin):
                response.headers['Access-Control-Allow-Origin'] = origin
                response.headers['Access-Control-Allow-Credentials'] = 'true'
            
            return response
    
    return SpecificCORSMiddleware

# Create the main app
app = FastAPI()

# Create FastAPI sub-applications with different CORS policies
public_app = FastAPI()
public_app.add_middleware(create_preflight_handler(
    allowed_origins=['http://localhost:3000', 'https://public-app.example.com'],
    allowed_methods=['GET', 'OPTIONS']
))

auth_app = FastAPI()
auth_app.add_middleware(create_preflight_handler(
    allowed_origins=['http://localhost:3000'],
    allowed_methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
))

admin_app = FastAPI()
admin_app.add_middleware(create_preflight_handler(
    allowed_origins=['https://admin.example.com'],
    allowed_methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']
))

# Define routes on each app
@public_app.get('/resources')
async def get_public_resources():
    return {'message': 'Public resources'}

@auth_app.put('/user/{user_id}')
async def update_user(user_id: int):
    return {'message': 'User updated'}

# Mount sub-applications with different CORS configurations
app.mount('/api/public', public_app)
app.mount('/api/auth', auth_app)
app.mount('/api/admin', admin_app)

if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app, host='0.0.0.0', port=8000)

This implementation provides fine-grained control over which origins, methods, and headers are allowed for different parts of your API. Each route type can have its own preflight handling configuration.

Setting Up Preflight Handling: Adding Preflight Logging and Diagnostics

To make debugging easier, let's add middleware to log preflight requests:

from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
import json
from datetime import datetime

class PreflightLoggerMiddleware(BaseHTTPMiddleware):
    """Middleware to log preflight requests for debugging purposes"""
    
    async def dispatch(self, request: Request, call_next):
        if request.method == 'OPTIONS':
            print(f"[PREFLIGHT] {datetime.now().isoformat()} - {request.method} {request.url.path}")
            print(f"  Origin: {request.headers.get('origin')}")
            print(f"  Requested Method: {request.headers.get('access-control-request-method')}")
            print(f"  Requested Headers: {request.headers.get('access-control-request-headers')}")
            
            # Process the request
            response = await call_next(request)
            
            # Log response headers
            print("  Responding with CORS headers:")
            print(f"    Access-Control-Allow-Origin: {response.headers.get('Access-Control-Allow-Origin')}")
            print(f"    Access-Control-Allow-Methods: {response.headers.get('Access-Control-Allow-Methods')}")
            print(f"    Access-Control-Allow-Headers: {response.headers.get('Access-Control-Allow-Headers')}")
            print(f"    Access-Control-Max-Age: {response.headers.get('Access-Control-Max-Age')}")
            
            return response
        
        return await call_next(request)

# Apply the logger middleware before CORS middleware
app = FastAPI()
app.add_middleware(PreflightLoggerMiddleware)
app.add_middleware(CustomCORSMiddleware, 
                  allowed_origins=['http://localhost:3000'],
                  max_age=600)

@app.get('/api/resources/{resource_id}')
async def get_resource(resource_id: int):
    return {'message': f'Resource {resource_id}'}

When a preflight request comes in, this middleware will log information like:

[PREFLIGHT] 2023-11-21T14:35:22.657000 - OPTIONS /api/resources/123
  Origin: http://localhost:3000
  Requested Method: PUT
  Requested Headers: content-type,authorization

  Responding with CORS headers:
    Access-Control-Allow-Origin: http://localhost:3000
    Access-Control-Allow-Methods: GET,POST,PUT,DELETE,PATCH,OPTIONS
    Access-Control-Allow-Headers: Content-Type,Authorization,X-Requested-With
    Access-Control-Max-Age: 600

This detailed logging helps you understand exactly what's happening with preflight requests and troubleshoot any CORS issues.

Manually Handling Preflight Requests Without CORS Middleware

While middleware handles preflight requests automatically, understanding how to handle them manually provides deeper insight:

from fastapi import FastAPI, Request
from starlette.responses import Response

app = FastAPI()

@app.options('/{full_path:path}')
async def handle_preflight(request: Request):
    """Manual preflight handler for all routes"""
    response = Response(status_code=204)  # No Content for preflight
    
    # Set CORS headers
    response.headers['Access-Control-Allow-Origin'] = 'http://localhost:3000'
    response.headers['Access-Control-Allow-Methods'] = 'GET,PUT,POST,DELETE,OPTIONS'
    response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization'
    response.headers['Access-Control-Max-Age'] = '600'
    response.headers['Access-Control-Allow-Credentials'] = 'true'
    
    return response

@app.middleware("http")
async def add_cors_headers(request: Request, call_next):
    """Add CORS headers to all responses"""
    response = await call_next(request)
    
    # Add CORS headers to actual responses
    response.headers['Access-Control-Allow-Origin'] = 'http://localhost:3000'
    response.headers['Access-Control-Allow-Credentials'] = 'true'
    
    return response

# Your routes
@app.get('/api/resources')
async def get_resources():
    return {'message': 'Resources retrieved'}

if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app, host='0.0.0.0', port=8000)

This manual approach gives you complete control over how preflight requests are handled, but requires more maintenance than using middleware.

Common Pitfalls and Misconceptions

When handling preflight requests in Python/FastAPI, there are several common pitfalls to avoid:

  1. Ignoring OPTIONS requests: Some developers forget to handle OPTIONS requests, causing preflight requests to fail. FastAPI middleware handles this automatically, but if you're building custom middleware, you need to respond to OPTIONS requests correctly.

  2. Misunderstanding preflight caching: Setting an appropriate max_age value can significantly improve performance by reducing redundant preflight requests. However, setting it too high might cause issues if you need to change CORS policies.

  3. Forgetting credentials: If your API uses cookies or authentication headers, you need to set allow_credentials=True and ensure your Access-Control-Allow-Origin is not set to a wildcard (*).

  4. Insufficient method allowance: Remember that the preflight request is checking what methods are allowed. If you don't include all methods your API needs in the allowed methods list, certain operations will fail.

  5. Incorrectly responding to manual OPTIONS requests: If you handle OPTIONS requests manually (without middleware), you must include all necessary CORS headers: Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers. Additionally, you should respond with a 204 (No Content) status code rather than 200, as shown in our manual example. Failing to include required headers or returning the wrong status code will cause the browser to reject the actual request.

  6. Middleware order matters: In FastAPI, middleware is executed in reverse order of how it's added. Make sure your CORS middleware runs before other middleware that might interfere with the response.

Conclusion and Next Steps

In this lesson, we explored preflight requests, a crucial aspect of the CORS mechanism. We learned how browsers use preflight requests to ensure secure cross-origin communication and how to properly configure our Python REST API using FastAPI to handle these requests. We implemented both automatic middleware-based and manual preflight handling systems with route-specific configurations and diagnostic logging to help troubleshoot issues.

Understanding and correctly implementing preflight request handling is essential for building secure, well-functioning APIs that can be accessed by web applications across different origins. In the upcoming practice exercises, you'll have the opportunity to apply what you've learned and strengthen your understanding of preflight requests. In our next lesson, we'll continue to enhance the security of your Python REST API with more advanced CORS configurations. Keep up the great work! 🌟

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal