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.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
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 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
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