Migrate Authentication from Better-Auth to fastapi-users
Summary
Replace the current Better-Auth (JavaScript) authentication setup with fastapi-users (Python) so that authentication is owned entirely by the FastAPI backend. This eliminates the architectural mismatch where a JavaScript auth library tries to sync with a Python backend.
Priority
P0 - Blocker — Required for multi-tenant user scoping. All database entities depend on user_id.
Problem Statement
Current State
- Frontend: Next.js with Better-Auth installed (cookies-only mode)
- Backend: FastAPI (Python) — the "real" application
- Database: SQLite (shared between both, but with schema conflicts)
- Failed Integration: Drizzle ORM was installed to try to bridge JS ↔ Python, but this doesn't work
Why It's Broken
Better-Auth is designed for JavaScript-only stacks. It expects to:
- Own and manage user/session tables with its specific schema
- Run in a Node.js environment
- Handle both auth logic AND database operations in JavaScript
This creates an impedance mismatch when a Python backend also needs to:
- Validate authentication state
- Access user IDs for multi-tenant data scoping
- Manage its own database models
The result is confusion about:
- Table names (
user vs users)
- ID types (string UUIDs vs integers)
- Password handling (who hashes, who validates)
- Two ORMs (Drizzle + SQLAlchemy) fighting over the same SQLite file
Target State
Python/FastAPI owns authentication completely. Next.js becomes a thin API client with no auth logic.
┌───────────────────────────────────────────────────────────┐
│ Next.js Frontend (UI only, no auth logic) │
│ - Calls /api/auth/login, /api/auth/register on backend │
│ - Stores JWT in memory or httpOnly cookie │
│ - Sends Authorization: Bearer <token> on API calls │
└───────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────┐
│ FastAPI Backend (owns everything) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ fastapi-users │ │
│ │ - /auth/register, /auth/login, /auth/logout │ │
│ │ - Password hashing (bcrypt) │ │
│ │ - JWT token generation & validation │ │
│ │ - Single source of truth for users table │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ SQLite - all tables owned by Python/SQLAlchemy │ │
│ │ - users (UUID primary key) │ │
│ │ - All business entities with user_id FK │ │
│ └─────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘
Implementation Tasks
Phase 1: Remove Better-Auth from Frontend
1.1 Remove Better-Auth dependencies
cd web-ui
npm uninstall better-auth @better-auth/client drizzle-orm drizzle-kit
1.2 Delete Better-Auth configuration files
Remove these files/directories if they exist:
web-ui/lib/auth.ts (Better-Auth server config)
web-ui/lib/auth-client.ts (Better-Auth client)
web-ui/app/api/auth/[...all]/route.ts (Better-Auth API routes)
web-ui/drizzle/ (Drizzle migrations/schema)
web-ui/drizzle.config.ts
- Any
*.db files created by Better-Auth in web-ui
1.3 Remove Better-Auth environment variables
Clean up .env and .env.local:
BETTER_AUTH_SECRET
BETTER_AUTH_URL
- Any Drizzle-related env vars
Phase 2: Install fastapi-users in Backend
2.1 Add dependencies
In pyproject.toml, add:
[project.dependencies]
# ... existing deps ...
fastapi-users = { extras = ["sqlalchemy"], version = "^13.0.0" }
aiosqlite = "^0.20.0"
python-jose = { extras = ["cryptography"], version = "^3.3.0" }
passlib = { extras = ["bcrypt"], version = "^1.7.4" }
Then run:
2.2 Create auth module structure
codeframe/
├── auth/
│ ├── __init__.py
│ ├── models.py # User SQLAlchemy model
│ ├── schemas.py # Pydantic schemas (UserRead, UserCreate, UserUpdate)
│ ├── users.py # fastapi-users configuration
│ ├── router.py # Auth route mounting
│ └── dependencies.py # current_active_user dependency
Phase 3: Implement Auth Models
3.1 User model (codeframe/auth/models.py)
"""User model for authentication."""
import uuid
from typing import Optional
from fastapi_users.db import SQLAlchemyBaseUserTableUUID
from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from codeframe.db import Base # Import existing Base
class User(SQLAlchemyBaseUserTableUUID, Base):
"""User model with multi-tenant support."""
__tablename__ = "users"
# Additional fields for CodeFRAME
display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
tenant_id: Mapped[Optional[uuid.UUID]] = mapped_column(nullable=True)
# Relationships to existing models can be added here
# projects: Mapped[list["Project"]] = relationship(back_populates="owner")
3.2 Pydantic schemas (codeframe/auth/schemas.py)
"""Pydantic schemas for user operations."""
import uuid
from typing import Optional
from fastapi_users import schemas
class UserRead(schemas.BaseUser[uuid.UUID]):
"""Schema for reading user data."""
display_name: Optional[str] = None
tenant_id: Optional[uuid.UUID] = None
class UserCreate(schemas.BaseUserCreate):
"""Schema for creating users."""
display_name: Optional[str] = None
class UserUpdate(schemas.BaseUserUpdate):
"""Schema for updating users."""
display_name: Optional[str] = None
Phase 4: Configure fastapi-users
4.1 User manager and auth backends (codeframe/auth/users.py)
"""fastapi-users configuration."""
import os
import uuid
from typing import Optional
from fastapi import Depends, Request
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin
from fastapi_users.authentication import (
AuthenticationBackend,
BearerTransport,
JWTStrategy,
)
from fastapi_users.db import SQLAlchemyUserDatabase
from sqlalchemy.ext.asyncio import AsyncSession
from codeframe.auth.models import User
from codeframe.db import get_async_session # You may need to create this
SECRET = os.getenv("AUTH_SECRET", "CHANGE-ME-IN-PRODUCTION-USE-SECRETS")
JWT_LIFETIME_SECONDS = 60 * 60 * 24 * 7 # 7 days
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
"""Custom user manager for CodeFRAME."""
reset_password_token_secret = SECRET
verification_token_secret = SECRET
async def on_after_register(self, user: User, request: Optional[Request] = None):
"""Called after successful registration."""
print(f"User {user.id} registered.")
async def on_after_login(
self, user: User, request: Optional[Request] = None, response=None
):
"""Called after successful login."""
print(f"User {user.id} logged in.")
async def get_user_db(session: AsyncSession = Depends(get_async_session)):
"""Dependency for user database adapter."""
yield SQLAlchemyUserDatabase(session, User)
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
"""Dependency for user manager."""
yield UserManager(user_db)
# JWT Bearer token transport
bearer_transport = BearerTransport(tokenUrl="auth/login")
def get_jwt_strategy() -> JWTStrategy:
"""JWT strategy for authentication."""
return JWTStrategy(secret=SECRET, lifetime_seconds=JWT_LIFETIME_SECONDS)
auth_backend = AuthenticationBackend(
name="jwt",
transport=bearer_transport,
get_strategy=get_jwt_strategy,
)
# FastAPIUsers instance
fastapi_users = FastAPIUsers[User, uuid.UUID](
get_user_manager,
[auth_backend],
)
# Dependency for protected routes
current_active_user = fastapi_users.current_user(active=True)
current_superuser = fastapi_users.current_user(active=True, superuser=True)
4.2 Mount auth routes (codeframe/auth/router.py)
"""Auth router configuration."""
from fastapi import APIRouter
from codeframe.auth.schemas import UserCreate, UserRead, UserUpdate
from codeframe.auth.users import auth_backend, fastapi_users
router = APIRouter()
# Authentication routes (login, logout)
router.include_router(
fastapi_users.get_auth_router(auth_backend),
prefix="/auth",
tags=["auth"],
)
# Registration route
router.include_router(
fastapi_users.get_register_router(UserRead, UserCreate),
prefix="/auth",
tags=["auth"],
)
# User management routes (get me, update me)
router.include_router(
fastapi_users.get_users_router(UserRead, UserUpdate),
prefix="/users",
tags=["users"],
)
# Optional: Reset password, verify email
# router.include_router(
# fastapi_users.get_reset_password_router(),
# prefix="/auth",
# tags=["auth"],
# )
# router.include_router(
# fastapi_users.get_verify_router(UserRead),
# prefix="/auth",
# tags=["auth"],
# )
Phase 5: Update FastAPI Application
5.1 Mount auth router in main app
In codeframe/ui/server.py (or wherever the FastAPI app is defined):
from codeframe.auth.router import router as auth_router
# Add auth routes
app.include_router(auth_router)
5.2 Create async database session (if not already async)
You may need to add an async session factory for fastapi-users. In codeframe/db.py:
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
ASYNC_DATABASE_URL = "sqlite+aiosqlite:///./codeframe.db"
async_engine = create_async_engine(ASYNC_DATABASE_URL)
async_session_maker = async_sessionmaker(async_engine, expire_on_commit=False)
async def get_async_session() -> AsyncSession:
async with async_session_maker() as session:
yield session
Phase 6: Update Database Schema
6.1 Create migration for users table
If using Alembic:
alembic revision --autogenerate -m "Add users table for fastapi-users"
alembic upgrade head
Or if letting SQLAlchemy create tables:
# In startup or a script
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
6.2 Clean up old Better-Auth tables
Remove any tables created by Better-Auth:
user (singular)
session
account
- Any Drizzle migration tracking tables
Phase 7: Update Existing Protected Routes
7.1 Add auth dependency to existing routes
Before:
@app.get("/api/projects")
async def get_projects():
return {"projects": [...]}
After:
from codeframe.auth.users import current_active_user
from codeframe.auth.models import User
@app.get("/api/projects")
async def get_projects(user: User = Depends(current_active_user)):
# user.id is now available for scoping
return db.query(Project).filter(Project.owner_id == user.id).all()
7.2 Create tenant context dependency (for multi-tenant scoping)
from fastapi import Depends
from codeframe.auth.users import current_active_user
from codeframe.auth.models import User
async def get_tenant_context(user: User = Depends(current_active_user)) -> dict:
"""Dependency that provides user and tenant context."""
return {
"user_id": user.id,
"tenant_id": user.tenant_id,
}
Phase 8: Update Frontend Auth Client
8.1 Create new auth client (web-ui/lib/auth.ts)
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
interface AuthTokens {
access_token: string;
token_type: string;
}
interface User {
id: string;
email: string;
display_name?: string;
is_active: boolean;
is_superuser: boolean;
is_verified: boolean;
}
export async function login(email: string, password: string): Promise<AuthTokens> {
const formData = new URLSearchParams();
formData.append('username', email); // fastapi-users expects 'username'
formData.append('password', password);
const res = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData,
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Login failed');
}
return res.json();
}
export async function register(email: string, password: string, displayName?: string): Promise<User> {
const res = await fetch(`${API_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
password,
display_name: displayName,
}),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Registration failed');
}
return res.json();
}
export async function getCurrentUser(token: string): Promise<User> {
const res = await fetch(`${API_URL}/users/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
throw new Error('Failed to get user');
}
return res.json();
}
export async function logout(token: string): Promise<void> {
await fetch(`${API_URL}/auth/logout`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
}
8.2 Create auth context/hook (web-ui/lib/auth-context.tsx)
'use client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { login as apiLogin, logout as apiLogout, getCurrentUser, register as apiRegister } from './auth';
interface User {
id: string;
email: string;
display_name?: string;
}
interface AuthContextType {
user: User | null;
token: string | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, displayName?: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check for existing token on mount
const storedToken = localStorage.getItem('auth_token');
if (storedToken) {
getCurrentUser(storedToken)
.then(setUser)
.catch(() => localStorage.removeItem('auth_token'))
.finally(() => setIsLoading(false));
setToken(storedToken);
} else {
setIsLoading(false);
}
}, []);
const login = async (email: string, password: string) => {
const { access_token } = await apiLogin(email, password);
localStorage.setItem('auth_token', access_token);
setToken(access_token);
const user = await getCurrentUser(access_token);
setUser(user);
};
const register = async (email: string, password: string, displayName?: string) => {
await apiRegister(email, password, displayName);
// Auto-login after registration
await login(email, password);
};
const logout = async () => {
if (token) {
await apiLogout(token);
}
localStorage.removeItem('auth_token');
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider value={{ user, token, isLoading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
Phase 9: Testing
9.1 Backend auth tests (tests/auth/test_auth.py)
"""Tests for authentication endpoints."""
import pytest
from httpx import AsyncClient
from codeframe.ui.server import app
@pytest.mark.asyncio
async def test_register_user():
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.post(
"/auth/register",
json={
"email": "test@example.com",
"password": "securepassword123",
},
)
assert response.status_code == 201
data = response.json()
assert data["email"] == "test@example.com"
assert "id" in data
@pytest.mark.asyncio
async def test_login_user():
async with AsyncClient(app=app, base_url="http://test") as client:
# Register first
await client.post(
"/auth/register",
json={"email": "login@example.com", "password": "password123"},
)
# Login
response = await client.post(
"/auth/login",
data={"username": "login@example.com", "password": "password123"},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
@pytest.mark.asyncio
async def test_protected_route_without_token():
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.get("/users/me")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_protected_route_with_token():
async with AsyncClient(app=app, base_url="http://test") as client:
# Register and login
await client.post(
"/auth/register",
json={"email": "protected@example.com", "password": "password123"},
)
login_response = await client.post(
"/auth/login",
data={"username": "protected@example.com", "password": "password123"},
)
token = login_response.json()["access_token"]
# Access protected route
response = await client.get(
"/users/me",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
assert response.json()["email"] == "protected@example.com"
9.2 Frontend auth tests
Add Playwright E2E tests for login/register flow.
Environment Variables
Add to .env.example:
# Authentication
AUTH_SECRET=your-secret-key-here-use-openssl-rand-hex-32
Migration Checklist
Acceptance Criteria
- Registration works: POST
/auth/register creates user in Python-owned users table
- Login returns JWT: POST
/auth/login returns { access_token, token_type }
- Protected routes work: Routes with
current_active_user dependency reject unauthenticated requests (401)
- User ID available:
user.id is accessible in route handlers for multi-tenant scoping
- Frontend can authenticate: Login/register forms work end-to-end
- No Better-Auth remnants: All JS auth code removed, no Drizzle, no Better-Auth tables
- Tests pass: Both backend unit tests and E2E auth tests pass
Related Issues
- Closes existing auth-related issues
Labels
bug, P0-blocker-beta, auth, refactor
Migrate Authentication from Better-Auth to fastapi-users
Summary
Replace the current Better-Auth (JavaScript) authentication setup with
fastapi-users(Python) so that authentication is owned entirely by the FastAPI backend. This eliminates the architectural mismatch where a JavaScript auth library tries to sync with a Python backend.Priority
P0 - Blocker — Required for multi-tenant user scoping. All database entities depend on
user_id.Problem Statement
Current State
Why It's Broken
Better-Auth is designed for JavaScript-only stacks. It expects to:
This creates an impedance mismatch when a Python backend also needs to:
The result is confusion about:
uservsusers)Target State
Python/FastAPI owns authentication completely. Next.js becomes a thin API client with no auth logic.
Implementation Tasks
Phase 1: Remove Better-Auth from Frontend
1.1 Remove Better-Auth dependencies
cd web-ui npm uninstall better-auth @better-auth/client drizzle-orm drizzle-kit1.2 Delete Better-Auth configuration files
Remove these files/directories if they exist:
web-ui/lib/auth.ts(Better-Auth server config)web-ui/lib/auth-client.ts(Better-Auth client)web-ui/app/api/auth/[...all]/route.ts(Better-Auth API routes)web-ui/drizzle/(Drizzle migrations/schema)web-ui/drizzle.config.ts*.dbfiles created by Better-Auth in web-ui1.3 Remove Better-Auth environment variables
Clean up
.envand.env.local:BETTER_AUTH_SECRETBETTER_AUTH_URLPhase 2: Install fastapi-users in Backend
2.1 Add dependencies
In
pyproject.toml, add:Then run:
2.2 Create auth module structure
Phase 3: Implement Auth Models
3.1 User model (
codeframe/auth/models.py)3.2 Pydantic schemas (
codeframe/auth/schemas.py)Phase 4: Configure fastapi-users
4.1 User manager and auth backends (
codeframe/auth/users.py)4.2 Mount auth routes (
codeframe/auth/router.py)Phase 5: Update FastAPI Application
5.1 Mount auth router in main app
In
codeframe/ui/server.py(or wherever the FastAPI app is defined):5.2 Create async database session (if not already async)
You may need to add an async session factory for fastapi-users. In
codeframe/db.py:Phase 6: Update Database Schema
6.1 Create migration for users table
If using Alembic:
alembic revision --autogenerate -m "Add users table for fastapi-users" alembic upgrade headOr if letting SQLAlchemy create tables:
6.2 Clean up old Better-Auth tables
Remove any tables created by Better-Auth:
user(singular)sessionaccountPhase 7: Update Existing Protected Routes
7.1 Add auth dependency to existing routes
Before:
After:
7.2 Create tenant context dependency (for multi-tenant scoping)
Phase 8: Update Frontend Auth Client
8.1 Create new auth client (
web-ui/lib/auth.ts)8.2 Create auth context/hook (
web-ui/lib/auth-context.tsx)Phase 9: Testing
9.1 Backend auth tests (
tests/auth/test_auth.py)9.2 Frontend auth tests
Add Playwright E2E tests for login/register flow.
Environment Variables
Add to
.env.example:# Authentication AUTH_SECRET=your-secret-key-here-use-openssl-rand-hex-32Migration Checklist
Acceptance Criteria
/auth/registercreates user in Python-owneduserstable/auth/loginreturns{ access_token, token_type }current_active_userdependency reject unauthenticated requests (401)user.idis accessible in route handlers for multi-tenant scopingRelated Issues
Labels
bug,P0-blocker-beta,auth,refactor