CORS Preflight Request fails with 401 in Preview Deployment

:red_exclamation_mark: Problem Description

I’m facing a CORS issue in my Preview Deployment on Vercel using FastAPI + Supabase.

Everything works fine in production, but in preview deployments I get 401 errors during preflight and authenticated API requests.

:cross_mark: Actual Behavior (Browser DevTools)

In Safari/Chrome:

[Error] Preflight response is not successful. Status code: 401  
[Error] Fetch API cannot load https://api-preview.domain.de/api/v1/auth/proxy/user due to access control checks.  
[Error] Failed to load resource: Preflight response is not successful. Status code: 401 (user, line 0)

[Error] Origin https://preview.domain.de is not allowed by Access-Control-Allow-Origin. Status code: 401  
[Error] XMLHttpRequest cannot load https://api-preview.domain.de/api/v1/stations/authorized due to access control checks.  
[Error] API Error: – "Network Error"  
(index.js:229:5752)  
[Error] Failed to load resource: Origin https://preview.domain.de is not allowed by Access-Control-Allow-Origin. Status code: 401 (authorized, line 0)

Additional Auth Flow Issues:

Wrong Login Credentials (500 Error):

[Error] Failed to load resource: the server responded with a status of https://api-preview.domain.de/api/v1/auth/login 500 () (login, line 0)
[Error] [ERROR] Sign in failed: – {message: "Login failed", stack: undefined, name: "Error"}
	error (index-DusbXmHd.js:49:34337)
	(anonyme Funktion) (index-DusbXmHd.js:49:35771)

Valid Login but Empty UI (CORS blocks user data):
After successful login with valid credentials, I get redirected but see:

  • Empty UI with no data
  • CORS preflight failures for authenticated endpoints
  • Redirect back to login page on reload
[Error] Preflight response is not successful. Status code: 401
[Error] Fetch API cannot load https://api-preview.domain.de/api/v1/auth/proxy/user due to access control checks.

The core issue: Even after successful authentication, CORS preflight requests fail with 401, preventing the UI from loading user data and maintaining the session.

:white_check_mark: My Current Setup & Configuration

Backend: FastAPI + Supabase on Vercel

FastAPI CORS Configuration:

# backend/src/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config import settings

app = FastAPI(
    title=settings.PROJECT_NAME,
    openapi_url=f"{settings.API_V1_STR}/openapi.json",
)

# Add Security Middleware (first for all requests)
app.add_middleware(SecurityMiddleware)

# CORS - Centralized configuration from settings
app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.cors_origins_list,
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
    allow_headers=[
        "Content-Type",
        "Authorization",
        "apikey",
        "x-client-info",
        "x-supabase-api-version",
        "x-forwarded-for",
        "x-real-ip",
    ],
    max_age=3600,
)

# Add Tenant Middleware for automatic tenant discovery
app.add_middleware(TenantMiddleware)

Security Middleware (Relevant for CORS Issues):

# backend/src/middleware/security_middleware.py
from fastapi import Request, Response, status
from starlette.middleware.base import BaseHTTPMiddleware
import logging

class SecurityMiddleware(BaseHTTPMiddleware):
    """Security middleware with path validation and security headers"""
    
    def __init__(self, app):
        super().__init__(app)
        self.blocked_paths = [
            "/../", "/.", "/admin", "/wp-admin", "/phpmyadmin", 
            "/.env", "/config", "/.git", "/backup"
        ]
        self.health_paths = ["/health", "/ping", "/status"]

    async def dispatch(self, request: Request, call_next) -> Response:
        """Security checks for every request"""
        # Skip security checks for OPTIONS requests (CORS preflight)
        if request.method == "OPTIONS":
            return await call_next(request)
            
        # 1. Path validation
        if self._is_blocked_path(request.url.path):
            return JSONResponse(
                status_code=status.HTTP_404_NOT_FOUND,
                content={"detail": "Not found"}
            )

        # 2. Process request
        response = await call_next(request)
        
        # 3. Add security headers
        self._add_security_headers(response)
        return response

Auth Endpoints:

# backend/src/api/endpoints/auth.py
@router.post("/login", response_model=LoginResponse)
async def login(login_data: LoginRequest, request: Request):
    """Endpoint für User Login with IP validation"""
    return await auth_service.login(login_data, request)

@router.get("/proxy/user", response_model=UserProfile)
async def get_user_profile(token: str = Depends(oauth2_scheme)):
    """Get current user profile - CORS issues prevent this from working in preview"""
    return await auth_service.get_user_profile(token)

Config with Environment-Aware CORS Origins:

# backend/src/config.py
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    # CORS settings - Environment aware domains
    CORS_ORIGINS: str = (
        "http://localhost:5173,"
        "https://www.domain.de,"
        "https://domain.de,"
        "https://preview.domain.de"
    )

    @property
    def cors_origins_list(self) -> list[str]:
        """
        Parse CORS origins string into list.
        Environment variable CORS_ORIGINS takes precedence over default.
        """
        origins = [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()]
        return origins

Backend Vercel Configuration

backend/vercel.json:

{
  "builds": [
    {
      "src": "src/main.py",
      "use": "@vercel/python",
      "config": {
        "runtime": "python3.11"
      }
    }
  ],
  "routes": [
    {
      "src": "/(.*)",
      "dest": "src/main.py",
      "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
    }
  ],
  "env": {
    "PYTHONPATH": "src"
  }
}

Frontend: React + Vite on Vercel

frontend/vercel.json:

{
  "rewrites": [
    {
      "source": "/(.*)",
      "destination": "/index.html"
    }
  ]
}

Environment Variables in Vercel

Preview Environment:

backend: CORS_ORIGINS=https://preview.domain.de 
frontend: VITE_API_BASE_URL=https://api-preview.domain.de/api/v1

:light_bulb: Solutions I’ve Tested

:cross_mark: What DOESN’T Work:

// Adding headers to vercel.json doesn't help because 
// 401 happens before headers are processed
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Access-Control-Allow-Origin",
          "value": "*"
        }
      ]
    }
  ]
}

:cross_mark: What DOESN’T Work:

# backend/src/middleware/vercel_cors_middleware.py
"""
🚀 Vercel-Specific CORS Middleware for Preview Deployments
Handles dynamic Vercel preview URLs and ensures OPTIONS requests work
"""

import re
import logging
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from typing import Callable
from ..config import get_settings

logger = logging.getLogger(__name__)
settings = get_settings()


class VercelCORSMiddleware(BaseHTTPMiddleware):
    """
    Enhanced CORS middleware specifically for Vercel deployments
    - Handles dynamic preview URLs
    - Ensures OPTIONS requests are processed correctly
    - Works with Vercel's serverless environment
    """

    def __init__(self, app):
        super().__init__(app)
        
        # Static allowed origins from config
        self.static_origins = settings.cors_origins_list
        
        # Regex patterns for dynamic Vercel URLs
        self.vercel_patterns = [
            # Pattern: https://project-name-git-branch-team.vercel.app
            re.compile(r'https://[\w-]+-git-[\w-]+-[\w-]+\.vercel\.app'),
            # Pattern: https://project-name-hash.vercel.app  
            re.compile(r'https://[\w-]+-[a-z0-9]{9}\.vercel\.app'),
            # Your specific preview domain
            re.compile(r'https://preview\.domain\.de'),
        ]
        
        # CORS headers matching your main CORS config
        self.cors_headers = {
            "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
            "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info, x-supabase-api-version, x-forwarded-for, x-real-ip",
            "Access-Control-Allow-Credentials": "true",
            "Access-Control-Max-Age": "3600",
        }

    async def dispatch(self, request: Request, call_next: Callable) -> Response:
        """Handle CORS for all requests, especially OPTIONS preflight"""
        
        origin = request.headers.get("origin")
        is_allowed = self._is_origin_allowed(origin)
        
        # Log for debugging Vercel deployments
        logger.info(f"🔍 CORS Check: {request.method} {request.url.path} from {origin}")
        logger.info(f"📍 Origin allowed: {is_allowed}")
        
        # Handle OPTIONS preflight requests immediately
        if request.method == "OPTIONS":
            logger.info("⚡ Handling OPTIONS preflight in CORS middleware")
            
            headers = {}
            
            # Add all CORS headers
            headers.update(self.cors_headers)
            
            # Add origin if allowed
            if is_allowed and origin:
                headers["Access-Control-Allow-Origin"] = origin
                logger.info(f"✅ Added CORS origin: {origin}")
            else:
                logger.warning(f"❌ Origin not allowed: {origin}")
                # Still return 200 for OPTIONS, but without origin header
                
            return JSONResponse(
                content=None,
                status_code=200,
                headers=headers
            )
        
        # Process actual request
        response = await call_next(request)
        
        # Add CORS headers to response
        if is_allowed and origin:
            response.headers["Access-Control-Allow-Origin"] = origin
            response.headers["Access-Control-Allow-Credentials"] = "true"
            
        return response
    
    def _is_origin_allowed(self, origin: str) -> bool:
        """Check if origin is allowed (static list + dynamic patterns)"""
        
        if not origin:
            return False
            
        # Check static allowed origins
        if origin in self.static_origins:
            logger.debug(f"✅ Static origin match: {origin}")
            return True
            
        # Check dynamic Vercel patterns
        for pattern in self.vercel_patterns:
            if pattern.match(origin):
                logger.debug(f"✅ Pattern match: {origin}")
                return True
                
        logger.debug(f"❌ No match for origin: {origin}")
        return False

:red_question_mark: Questions for Community

  1. Has anyone found a Vercel-native way to allow unauthenticated OPTIONS requests on preview deployments?

  2. Best practices for testing backend changes when using production backend for preview frontend?

  3. Alternative approaches to handle CORS in serverless Python deployments?

:wrench: Complete Setup Overview

Tags: #FastAPI #Vercel #CORS python #Preview #Deployment #Preflight