❗ 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.
⸻
❌ 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.
⸻
✅ My Current Setup & Configuration
Backend: FastAPI + Supabase on Vercel
FastAPI CORS Configuration:
# backend/src/main.pyfrom fastapi import FastAPIfrom fastapi.middleware.cors import CORSMiddlewarefrom 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 settingsapp.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 discoveryapp.add_middleware(TenantMiddleware)Security Middleware (Relevant for CORS Issues):
# backend/src/middleware/security_middleware.pyfrom fastapi import Request, Response, statusfrom starlette.middleware.base import BaseHTTPMiddlewareimport 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 responseAuth 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.pyfrom 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 originsBackend 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💡 Solutions I've Tested
❌ 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": "*" } ] } ]}❌ What DOESN'T Work:
# backend/src/middleware/vercel_cors_middleware.py"""🚀 Vercel-Specific CORS Middleware for Preview DeploymentsHandles dynamic Vercel preview URLs and ensures OPTIONS requests work"""
import reimport loggingfrom fastapi import Request, Responsefrom fastapi.responses import JSONResponsefrom starlette.middleware.base import BaseHTTPMiddlewarefrom typing import Callablefrom ..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❓ Questions for Community
-
Has anyone found a Vercel-native way to allow unauthenticated OPTIONS requests on preview deployments?
-
Best practices for testing backend changes when using production backend for preview frontend?
-
Alternative approaches to handle CORS in serverless Python deployments?
🔧 Complete Setup Overview
- Backend: FastAPI + Supabase on Vercel
- Frontend: React + Vite on Vercel
- Preview Frontend: https://preview.domain.de
- Preview API: https://api-preview.domain.de
- Production API: https://api.domain.de
- Vercel Hobby Plan
Tags: #FastAPI #Vercel #CORS #Python #Preview #Deployment #Preflight