CORS Error Handling and Authentication Token Management
Problem
When the FastAPI application generates a 500 error, the response is sent to the client without the CORS headers. This causes browsers to block the response with a misleading CORS error message:
Access to XMLHttpRequest at 'http://localhost:8100/api/v1/production-runs/10/trackers' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
This gives a false impression that the client is suffering from a CORS-related issue, when there is actually a different underlying cause (a server error).
Vite Proxy Configuration
When using Vite's development server with a proxy configuration, it's important to understand how the proxy works:
- The proxy forwards requests from the Vite dev server to the target server
- Requests must be made to the Vite server (using relative URLs), not directly to the target server
- The proxy configuration in
vite.config.tsshould use patterns that match all API routes
Common Issues with Vite Proxy
If you're experiencing CORS errors with the Vite proxy, check for these common issues:
- Using absolute URLs in API requests: Always use relative URLs (e.g.,
/api/v1/usersinstead ofhttp://localhost:8000/api/v1/users) - Restrictive proxy patterns: Use more general patterns like
/apiinstead of specific regex patterns - Missing proxy options: Ensure
changeOrigin: trueis set in the proxy configuration
Our Solution
We've updated the Vite proxy configuration to handle HTTP redirects and use Docker service names:
server: {
proxy: {
'/api/v1': { // This matches paths starting with /api/v1
target: 'http://dev:8000',
changeOrigin: true,
secure: false,
followRedirects: true, // Automatically follow HTTP redirects
// Other options...
},
'/static': { // This matches all paths starting with /static
target: 'http://dev:8000',
changeOrigin: true,
secure: false,
followRedirects: true, // Automatically follow HTTP redirects
},
},
},
This configuration ensures that all API requests are properly proxied to the backend server, avoiding CORS issues and handling redirects correctly.
Our API client in tracker-admin/src/api/client.ts is already configured to use relative URLs and includes safeguards to convert any absolute URLs to relative ones.
Key Changes Made
- More specific proxy pattern: Using
/api/v1to match exactly what the API client is using - Docker service name: Using
http://dev:8000as the target, where "dev" is the Docker service name - Handling redirects: Added
followRedirects: trueto automatically follow HTTP redirects (like 307 Temporary Redirect) - Enhanced logging: Added logging for redirect locations to help troubleshoot redirect issues
Understanding HTTP Redirects in Proxies
HTTP redirects (like 307 Temporary Redirect) can cause issues with proxies if not handled correctly:
- Default behavior: By default, the Vite proxy doesn't automatically follow redirects
- Client-side redirects: Without
followRedirects: true, the browser would need to handle the redirect, which can break the proxy - Relative vs. absolute redirects: Redirects with relative paths work differently than those with absolute URLs
- Preserving HTTP methods: 307 redirects preserve the original HTTP method (GET, POST, etc.), which is important for API requests
The Critical Role of 307 Temporary Redirect
The 307 Temporary Redirect status code is particularly important in API proxying scenarios:
- What is a 307 Redirect? A 307 status code tells the client to make the same request (with the same HTTP method and body) to a different URL
- Why it's used in APIs: Many APIs use 307 redirects for load balancing, URL normalization, or to handle moved resources
- Proxy challenges: When a proxy receives a 307 response but doesn't follow it, the client receives the redirect instead of the actual response
- CORS implications: If the redirect target has a different origin, it can trigger CORS errors even if the original request would have been allowed
In our case, the backend was returning 307 redirects, but the Vite proxy wasn't following them. This caused requests to fail with CORS errors or network errors. By adding followRedirects: true to our proxy configuration, we ensure that the proxy handles these redirects internally, making the process transparent to the client.
Example log showing a 307 redirect:
Sending Request to the Target: GET /api/v1/images?skip=0&limit=20
Proxy Request Headers: [Object: null prototype] { ... }
Received Response from the Target: 307 /api/v1/images?skip=0&limit=20
Redirect Location: /api/v1/images/?skip=0&limit=20
Notice the subtle difference in the redirect location - the addition of a trailing slash. Without proper redirect handling, this small difference can break your application.
Troubleshooting Network Errors
If you're still seeing network errors, check:
- That all Docker services are running (
docker-compose psto verify) - That you're using relative URLs in all API requests (e.g.,
/api/v1/usersinstead ofhttp://dev:8000/api/v1/users) - That the proxy configuration in
vite.config.tsis correctly set up - That there are no CORS headers being explicitly set in your API requests that might override the proxy settings
- That your browser's network tab shows the requests are being made to the correct URL (should be to localhost:3000)
- That the server logs show the correct redirect paths (if any)
Common Issues with Vite Proxy in Docker Environments
When working with Docker and Vite proxy, several issues can arise:
- Docker networking: Docker containers have their own network namespace, which allows them to use service names as hostnames
- Port mapping: Make sure you're using the correct port (8000 inside the container, 8100 mapped to the host)
- CORS headers: The backend server must be configured to accept requests from the frontend's origin
- HTTP redirects: Redirects can break the proxy if not handled correctly with
followRedirects: true
Solution
To address this issue, we've implemented a custom middleware called CORSHeadersMiddleware that ensures CORS headers are included in all responses, including error responses.
The middleware:
- Intercepts all requests and responses
- Adds appropriate CORS headers to all responses
- Catches unhandled exceptions and returns a 500 response with CORS headers
This ensures that even when errors occur, the browser will receive the proper CORS headers and display the actual error instead of a misleading CORS error.
Implementation
The implementation is in app/main.py and consists of:
# Custom middleware to ensure CORS headers are present in all responses, including errors
class CORSHeadersMiddleware(BaseHTTPMiddleware):
def __init__(
self,
app: ASGIApp,
origins: list[str] = ["*"],
allow_credentials: bool = False
):
super().__init__(app)
self.origins = origins
self.allow_credentials = allow_credentials
async def dispatch(self, request: Request, call_next: Callable) -> Response:
try:
# Try to process the request normally
response = await call_next(request)
# Ensure CORS headers are present in the response
self._add_cors_headers(request, response)
return response
except Exception as e:
# For unhandled exceptions, create a response with CORS headers
response = JSONResponse(
status_code=500,
content={"detail": f"Internal Server Error: {str(e)}"},
)
# Add CORS headers to the error response
self._add_cors_headers(request, response)
return response
def _add_cors_headers(self, request: Request, response: Response) -> None:
origin = request.headers.get("origin", "")
# If we have a specific list of origins, check if the request origin is allowed
if "*" not in self.origins and origin:
if origin in self.origins:
response.headers["Access-Control-Allow-Origin"] = origin
else:
# For wildcard, we can just set the header to *
response.headers["Access-Control-Allow-Origin"] = "*"
# Add other CORS headers
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS, PATCH"
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-Requested-With"
if self.allow_credentials:
response.headers["Access-Control-Allow-Credentials"] = "true"
The middleware is added to the application before the standard FastAPI CORSMiddleware:
# Add performance monitoring middleware
app.add_middleware(PerformanceMiddleware)
# Set all CORS enabled origins
if "*" in settings.CORS_ORIGINS:
# If wildcard is present, use it but disable credentials
app.add_middleware(
CORSHeadersMiddleware,
origins=["*"],
allow_credentials=False,
)
else:
# Otherwise use specific origins with credentials
app.add_middleware(
CORSHeadersMiddleware,
origins=settings.CORS_ORIGINS,
allow_credentials=True,
)
# Add standard CORS middleware as a fallback
if "*" in settings.CORS_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
else:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Why Two Middleware Layers?
We've added both our custom CORSHeadersMiddleware and the standard FastAPI CORSMiddleware for a few reasons:
- The custom middleware ensures CORS headers are added to error responses
- The standard middleware handles the normal CORS preflight requests and other CORS-related functionality
- Having both provides a fallback mechanism in case one fails
Testing
To test this implementation, you can:
- Intentionally cause a server error (e.g., by requesting a non-existent resource or triggering an exception in an endpoint)
- Check the browser's network tab to verify that the response includes the CORS headers
- Confirm that the browser displays the actual error message instead of a CORS error
Troubleshooting
If you're still seeing CORS errors:
- Check that the middleware is being applied correctly
- Verify that the origins in
settings.CORS_ORIGINSinclude the origin of your client application - Ensure that the middleware is being added before the routes are registered
- Check for any other middleware that might be interfering with the CORS headers
Authentication Token Management
Problem
The admin panel was experiencing 403 errors when authentication tokens expired, which were not being handled gracefully. This led to a poor user experience where users would suddenly lose access to the application without warning.
Solution
We've implemented an automatic token refresh mechanism in the frontend that:
- Checks if the access token is about to expire (within 10 minutes of expiration)
- Automatically refreshes the token before it expires using the refresh token stored in an HttpOnly cookie
- Queues any in-flight requests during the refresh process and retries them with the new token
- Handles 401 errors by attempting to refresh the token and retrying the request
This ensures a seamless user experience without interruptions due to token expiration.
Implementation
The implementation consists of:
- A token refresh endpoint in the backend (
/auth/refresh-cookie) that uses the HttpOnly refresh token cookie - A function to check if a token is about to expire
- Request and response interceptors in the API client that handle token refresh
Token Expiry Check
// Function to check if token is about to expire (within 10 minutes)
const isTokenAboutToExpire = (token: string): boolean => {
try {
const decoded = jwtDecode<{ exp: number }>(token);
const currentTime = Date.now() / 1000;
// Check if token will expire in less than 10 minutes (600 seconds)
return decoded.exp - currentTime < 600;
} catch {
return true; // If we can't decode the token, assume it's about to expire
}
};
Request Interceptor
The request interceptor checks if the token is about to expire and refreshes it if needed:
// Request interceptor for adding auth token and handling token refresh
apiClient.interceptors.request.use(async (config) => {
const token = localStorage.getItem("token");
// If we have a token and it's about to expire, try to refresh it
if (
token &&
isTokenAboutToExpire(token) &&
!config.url?.includes("/auth/refresh")
) {
// Refresh token logic here...
}
// Add token to request
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
Response Interceptor
The response interceptor handles 401 errors by attempting to refresh the token and retrying the request:
// Response interceptor for handling errors
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const { response, config } = error;
// Skip if this is a refresh request to avoid infinite loops
const isRefreshRequest = config?.url?.includes("/auth/refresh");
if (response && response.status === 401 && !isRefreshRequest) {
// Token refresh and retry logic here...
}
// Other error handling...
return Promise.reject(error);
},
);
Testing
To test this implementation, you can:
- Log in to the admin panel
- Wait until the token is close to expiration (or modify the token in localStorage to have an earlier expiration)
- Make a request and verify that the token is refreshed automatically
- Check the network tab to see the refresh request and the subsequent request with the new token
Troubleshooting
If you're experiencing authentication issues:
- Check that the refresh token cookie is being set correctly
- Verify that the token refresh endpoint is working as expected
- Ensure that the
withCredentials: trueoption is set for cross-origin requests - Check that the CORS configuration allows credentials