4.9 KiB
FastAPI Trailing Slash Issue - 307 Redirect & 403 Forbidden
Problem Description
When making authenticated API calls to FastAPI endpoints, a mismatch in trailing slashes between the frontend request and backend route definition causes a 307 Temporary Redirect that loses the authentication header, resulting in a 403 Forbidden error.
Root Cause
FastAPI automatically redirects requests to add or remove trailing slashes to match the route definition:
- If route is defined as
@router.get("/tasks/")(with slash) and you call/tasks(without slash) → 307 redirect to/tasks/ - If route is defined as
@router.get("/tasks")(without slash) and you call/tasks/(with slash) → 307 redirect to/tasks
The problem: HTTP redirects (307) do NOT preserve the Authorization header by default, so the redirected request arrives without authentication, causing a 403 Forbidden error.
Symptoms
Backend Logs
INFO: 127.0.0.1:59653 - "GET /tasks?shot_id=12 HTTP/1.1" 307 Temporary Redirect
INFO: 127.0.0.1:59615 - "GET /tasks/?shot_id=12 HTTP/1.1" 403 Forbidden
Frontend Console
GET http://localhost:8000/tasks/?shot_id=12 403 (Forbidden)
AxiosError {message: 'Request failed with status code 403', ...}
Solution
Always ensure trailing slashes match between frontend API calls and backend route definitions.
Option 1: Add Trailing Slash to Frontend (Recommended)
Frontend Service:
// ❌ WRONG - No trailing slash
const response = await apiClient.get(`/tasks?${params}`)
// ✅ CORRECT - With trailing slash
const response = await apiClient.get(`/tasks/?${params}`)
Backend Route:
# Route defined WITH trailing slash
@router.get("/tasks/")
async def get_tasks(...):
...
Option 2: Remove Trailing Slash from Backend
Backend Route:
# Route defined WITHOUT trailing slash
@router.get("/tasks")
async def get_tasks(...):
...
Frontend Service:
// Call WITHOUT trailing slash
const response = await apiClient.get(`/tasks?${params}`)
Prevention Checklist
When adding or modifying routes, always check:
-
Backend Route Definition - Does it have a trailing slash?
@router.get("/endpoint/") # Has trailing slash @router.get("/endpoint") # No trailing slash -
Frontend API Call - Does it match the backend?
apiClient.get(`/endpoint/`) // Has trailing slash apiClient.get(`/endpoint`) // No trailing slash -
Query Parameters - Trailing slash goes BEFORE the
?// ✅ CORRECT apiClient.get(`/tasks/?shot_id=12`) // ❌ WRONG apiClient.get(`/tasks?shot_id=12/`) -
Path Parameters - Usually no trailing slash
// ✅ CORRECT apiClient.get(`/tasks/${taskId}`) // ❌ WRONG (usually) apiClient.get(`/tasks/${taskId}/`)
Historical Issues Fixed
Issue 1: Shots Endpoint (Fixed)
- Problem: Frontend called
/shots/1/but backend defined/shots/{shot_id} - Solution: Changed frontend to call
/shots/1(no trailing slash) - Files:
frontend/src/services/shot.ts
Issue 2: Tasks Endpoint (Fixed)
- Problem: Frontend called
/tasks?shot_id=12but backend defined/tasks/ - Solution: Changed frontend to call
/tasks/?shot_id=12(with trailing slash) - Files:
frontend/src/services/task.ts
Testing
To verify there's no redirect issue:
-
Check backend logs - Should see only ONE request, not two:
✅ GOOD: INFO: "GET /tasks/?shot_id=12 HTTP/1.1" 200 OK ❌ BAD (redirect happening): INFO: "GET /tasks?shot_id=12 HTTP/1.1" 307 Temporary Redirect INFO: "GET /tasks/?shot_id=12 HTTP/1.1" 403 Forbidden -
Check frontend network tab - Should see 200 OK, not 307 or 403
-
Test with authentication - Ensure authenticated endpoints work correctly
Quick Reference
Common Patterns
| Endpoint Type | Backend Route | Frontend Call |
|---|---|---|
| List with query params | @router.get("/items/") |
get("/items/?param=value") |
| Get by ID | @router.get("/items/{id}") |
get("/items/123") |
| Create | @router.post("/items/") |
post("/items/", data) |
| Update by ID | @router.put("/items/{id}") |
put("/items/123", data) |
| Delete by ID | @router.delete("/items/{id}") |
delete("/items/123") |
Related Files
- Backend routes:
backend/routers/*.py - Frontend services:
frontend/src/services/*.ts - API client:
frontend/src/services/api.ts
Additional Notes
- This issue only affects authenticated endpoints because the
Authorizationheader is lost during redirect - Public endpoints might not show this issue as clearly
- Always test with actual authentication tokens, not just in development mode
- Consider adding a linter rule or pre-commit hook to check for trailing slash consistency