Scribe uses Supabase JWT authentication with a backend-first architecture. The frontend handles OAuth and login, while the backend validates tokens and performs all database operations.
Security Model: Frontend uses Supabase only for authentication. Backend uses service role key for database access with JWT validation.
Validates JWT token via Supabase API without checking the local database.
api/dependencies.py
async def get_supabase_user( creds: HTTPAuthorizationCredentials = Security(security_scheme),) -> SupabaseUser: """ Validate JWT token via Supabase API. Does not check local database. Raises: HTTPException: 401 if token invalid, 503 if Supabase unavailable """ token = creds.credentials # Get Supabase client try: supabase = get_supabase_client() except Exception as e: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"Supabase client not available: {str(e)}", ) # Validate token with Supabase try: # This makes a network call to Supabase to validate the token supabase_response = supabase.auth.get_user(token) # Extract user data from response if not supabase_response or not supabase_response.user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token: no user data returned", ) user_data = supabase_response.user # Return validated user data return SupabaseUser( id=user_data.id, email=user_data.email, ) except AuthApiError as e: # Supabase-specific auth errors (invalid/expired token, etc.) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid token: {e.message}", ) except HTTPException: # Re-raise HTTP exceptions raise except Exception as e: # Catch any other unexpected errors raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Token validation failed: {str(e)}", )
This function makes a network call to Supabase on every request. For high-traffic scenarios, consider implementing token caching with expiry checks.
Retrieves the authenticated user from the local database after JWT validation.
api/dependencies.py
async def get_current_user( supabase_user: SupabaseUser = Depends(get_supabase_user), db: Session = Depends(get_db),) -> User: """ Get authenticated user from local database after JWT validation. Raises: HTTPException: 403 if user not initialized (requires POST /api/user/init) """ # Query local database for user db_user = db.query(User).filter(User.id == supabase_user.id).first() if not db_user: # Valid Supabase user, but not initialized in our backend raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User profile not initialized. Please call POST /api/user/init first.", ) return db_user
Users must call POST /api/user/init after Supabase signup to create a local database record. This separates authentication (Supabase) from authorization (local DB).
from fastapi import APIRouter, Dependsfrom api.dependencies import get_current_userfrom models.user import Userrouter = APIRouter(prefix="/api/queue", tags=["Queue"])@router.post("/batch")async def submit_batch( batch_request: BatchSubmitRequest, current_user: User = Depends(get_current_user), # ✅ Authenticated user db: Session = Depends(get_db),): """ Submit batch of emails. Only authenticated users can call this. """ # current_user is validated and from database # User ID is from JWT token, not request body queue_item = QueueItem( user_id=current_user.id, # ✅ Use validated user ID recipient_name=batch_request.recipient_name, ... ) db.add(queue_item) db.commit() return {"message": "Batch submitted"}
# 1. Get JWT token from Supabase (use frontend or Supabase CLI)export TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."# 2. Test protected endpointcurl -X GET http://localhost:8000/api/queue/ \ -H "Authorization: Bearer $TOKEN"# Expected: 200 OK with queue items# If 401: Token invalid or expired# If 403: User not initialized