Better Auth Integration
Quick reference for integrating Better Auth with Next.js frontend and FastAPI backend for the Todo Web Application Phase 2.
Overview
Better Auth provides:
-
JWT-based authentication
-
Social OAuth providers (optional)
-
Session management
-
Secure cookie handling
-
Type-safe client
Architecture
┌─────────────────────┐ ┌─────────────────────┐ │ Next.js Frontend │ │ FastAPI Backend │ │ │ │ │ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │ │ Better Auth │ │────▶│ │ JWT Validator │ │ │ │ Client │ │ │ │ Middleware │ │ │ └───────────────┘ │ │ └───────────────┘ │ │ │ │ │ │ │ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │ │ Auth Context │ │ │ │ Protected │ │ │ │ Provider │ │ │ │ Routes │ │ │ └───────────────┘ │ │ └───────────────┘ │ └─────────────────────┘ └─────────────────────┘
Frontend Setup (Next.js)
- Install Dependencies
cd frontend npm install better-auth @better-auth/client
- Environment Variables
Create frontend/.env.local :
Better Auth Configuration
BETTER_AUTH_SECRET=your-super-secret-key-min-32-chars NEXT_PUBLIC_API_URL=http://localhost:8000
NextAuth URL (for local development)
NEXTAUTH_URL=http://localhost:3000
- Auth Configuration
Create frontend/src/lib/auth.ts :
import { createAuthClient } from "@better-auth/client";
// Create auth client export const authClient = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000", });
// Export commonly used methods export const { signIn, signUp, signOut, useSession, getSession } = authClient;
- Auth Provider
Create frontend/src/components/providers/auth-provider.tsx :
"use client";
import { createContext, useContext, useEffect, useState, ReactNode } from "react"; import { authClient } from "@/lib/auth";
interface User { id: string; email: string; name?: string; }
interface AuthContextType { user: User | null; isLoading: boolean; isAuthenticated: boolean; signIn: (email: string, password: string) => Promise<void>; signUp: (email: string, password: string, name?: string) => Promise<void>; signOut: () => Promise<void>; getToken: () => Promise<string | null>; }
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState<User | null>(null); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { // Check for existing session on mount checkSession(); }, []);
const checkSession = async () => { try { const session = await authClient.getSession(); if (session?.user) { setUser(session.user); } } catch (error) { console.error("Session check failed:", error); } finally { setIsLoading(false); } };
const signIn = async (email: string, password: string) => { setIsLoading(true); try { const result = await authClient.signIn.email({ email, password, }); if (result.user) { setUser(result.user); } } finally { setIsLoading(false); } };
const signUp = async (email: string, password: string, name?: string) => { setIsLoading(true); try { const result = await authClient.signUp.email({ email, password, name: name || email.split("@")[0], }); if (result.user) { setUser(result.user); } } finally { setIsLoading(false); } };
const signOut = async () => { setIsLoading(true); try { await authClient.signOut(); setUser(null); } finally { setIsLoading(false); } };
const getToken = async (): Promise<string | null> => { const session = await authClient.getSession(); return session?.token || null; };
return ( <AuthContext.Provider value={{ user, isLoading, isAuthenticated: !!user, signIn, signUp, signOut, getToken, }} > {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; }
- Protected Route Middleware
Create frontend/src/middleware.ts :
import { NextResponse } from "next/server"; import type { NextRequest } from "next/server";
// Routes that require authentication const protectedRoutes = ["/tasks", "/dashboard", "/settings"];
// Routes that should redirect to dashboard if authenticated const authRoutes = ["/login", "/signup"];
export function middleware(request: NextRequest) { const token = request.cookies.get("auth-token")?.value; const { pathname } = request.nextUrl;
// Check if accessing protected route without token if (protectedRoutes.some((route) => pathname.startsWith(route))) { if (!token) { const loginUrl = new URL("/login", request.url); loginUrl.searchParams.set("redirect", pathname); return NextResponse.redirect(loginUrl); } }
// Redirect authenticated users away from auth pages if (authRoutes.some((route) => pathname.startsWith(route))) { if (token) { return NextResponse.redirect(new URL("/tasks", request.url)); } }
return NextResponse.next(); }
export const config = { matcher: ["/tasks/:path*", "/dashboard/:path*", "/login", "/signup"], };
- Login Form Component
Create frontend/src/components/auth/login-form.tsx :
"use client";
import { useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/components/providers/auth-provider"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; import Link from "next/link";
export function LoginForm() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState<string | null>(null); const [isLoading, setIsLoading] = useState(false);
const { signIn } = useAuth(); const router = useRouter(); const searchParams = useSearchParams(); const redirectTo = searchParams.get("redirect") || "/tasks";
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); setIsLoading(true);
try {
await signIn(email, password);
router.push(redirectTo);
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed. Please try again.");
} finally {
setIsLoading(false);
}
};
return ( <Card className="w-full max-w-md"> <CardHeader> <CardTitle>Welcome Back</CardTitle> <CardDescription>Sign in to your account to continue</CardDescription> </CardHeader> <form onSubmit={handleSubmit}> <CardContent className="space-y-4"> {error && ( <Alert variant="destructive"> <AlertDescription>{error}</AlertDescription> </Alert> )} <div className="space-y-2"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="you@example.com" value={email} onChange={(e) => setEmail(e.target.value)} required disabled={isLoading} /> </div> <div className="space-y-2"> <Label htmlFor="password">Password</Label> <Input id="password" type="password" placeholder="••••••••" value={password} onChange={(e) => setPassword(e.target.value)} required disabled={isLoading} minLength={8} /> </div> </CardContent> <CardFooter className="flex flex-col space-y-4"> <Button type="submit" className="w-full" disabled={isLoading}> {isLoading ? "Signing in..." : "Sign In"} </Button> <p className="text-sm text-muted-foreground"> Don't have an account?{" "} <Link href="/signup" className="text-primary hover:underline"> Sign up </Link> </p> </CardFooter> </form> </Card> ); }
- Signup Form Component
Create frontend/src/components/auth/signup-form.tsx :
"use client";
import { useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/components/providers/auth-provider"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; import Link from "next/link";
export function SignupForm() { const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState<string | null>(null); const [isLoading, setIsLoading] = useState(false);
const { signUp } = useAuth(); const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null);
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
if (password.length < 8) {
setError("Password must be at least 8 characters");
return;
}
setIsLoading(true);
try {
await signUp(email, password, name);
router.push("/tasks");
} catch (err) {
setError(err instanceof Error ? err.message : "Signup failed. Please try again.");
} finally {
setIsLoading(false);
}
};
return ( <Card className="w-full max-w-md"> <CardHeader> <CardTitle>Create Account</CardTitle> <CardDescription>Sign up to start managing your tasks</CardDescription> </CardHeader> <form onSubmit={handleSubmit}> <CardContent className="space-y-4"> {error && ( <Alert variant="destructive"> <AlertDescription>{error}</AlertDescription> </Alert> )} <div className="space-y-2"> <Label htmlFor="name">Name</Label> <Input id="name" type="text" placeholder="John Doe" value={name} onChange={(e) => setName(e.target.value)} disabled={isLoading} /> </div> <div className="space-y-2"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="you@example.com" value={email} onChange={(e) => setEmail(e.target.value)} required disabled={isLoading} /> </div> <div className="space-y-2"> <Label htmlFor="password">Password</Label> <Input id="password" type="password" placeholder="••••••••" value={password} onChange={(e) => setPassword(e.target.value)} required disabled={isLoading} minLength={8} /> </div> <div className="space-y-2"> <Label htmlFor="confirmPassword">Confirm Password</Label> <Input id="confirmPassword" type="password" placeholder="••••••••" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} required disabled={isLoading} minLength={8} /> </div> </CardContent> <CardFooter className="flex flex-col space-y-4"> <Button type="submit" className="w-full" disabled={isLoading}> {isLoading ? "Creating account..." : "Create Account"} </Button> <p className="text-sm text-muted-foreground"> Already have an account?{" "} <Link href="/login" className="text-primary hover:underline"> Sign in </Link> </p> </CardFooter> </form> </Card> ); }
Backend Setup (FastAPI)
- Install Dependencies
cd backend uv add python-jose[cryptography] passlib[bcrypt] pydantic-settings
- Environment Variables
Add to backend/.env :
JWT Configuration
JWT_SECRET_KEY=your-super-secret-key-min-32-chars JWT_ALGORITHM=HS256 JWT_ACCESS_TOKEN_EXPIRE_MINUTES=10080 # 7 days
Security
CORS_ORIGINS=http://localhost:3000
- Auth Configuration
Create backend/src/config.py :
from pydantic_settings import BaseSettings from functools import lru_cache
class Settings(BaseSettings): """Application settings loaded from environment variables."""
# Database
database_url: str
# JWT
jwt_secret_key: str
jwt_algorithm: str = "HS256"
jwt_access_token_expire_minutes: int = 10080 # 7 days
# Security
cors_origins: str = "http://localhost:3000"
@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",")]
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
@lru_cache def get_settings() -> Settings: return Settings()
- JWT Utilities
Create backend/src/utils/jwt.py :
from datetime import datetime, timedelta from typing import Optional from jose import JWTError, jwt from passlib.context import CryptContext from pydantic import BaseModel
from src.config import get_settings
settings = get_settings()
Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class TokenPayload(BaseModel): """JWT token payload.""" sub: str # user_id email: str exp: datetime iat: datetime
class TokenData(BaseModel): """Decoded token data.""" user_id: str email: str
def verify_password(plain_password: str, hashed_password: str) -> bool: """Verify a password against its hash.""" return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str: """Hash a password.""" return pwd_context.hash(password)
def create_access_token(user_id: str, email: str, expires_delta: Optional[timedelta] = None) -> str: """Create a JWT access token.""" if expires_delta: expire = datetime.utcnow() + expires_delta else: expire = datetime.utcnow() + timedelta(minutes=settings.jwt_access_token_expire_minutes)
payload = {
"sub": user_id,
"email": email,
"exp": expire,
"iat": datetime.utcnow(),
}
encoded_jwt = jwt.encode(
payload,
settings.jwt_secret_key,
algorithm=settings.jwt_algorithm
)
return encoded_jwt
def decode_access_token(token: str) -> Optional[TokenData]: """Decode and validate a JWT access token.""" try: payload = jwt.decode( token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm] ) user_id: str = payload.get("sub") email: str = payload.get("email")
if user_id is None or email is None:
return None
return TokenData(user_id=user_id, email=email)
except JWTError:
return None
5. Auth Middleware
Create backend/src/middleware/auth.py :
from fastapi import Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from typing import Optional from pydantic import BaseModel
from src.utils.jwt import decode_access_token, TokenData
security = HTTPBearer()
class CurrentUser(BaseModel): """Current authenticated user.""" id: str email: str
async def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security) ) -> CurrentUser: """ Dependency to get the current authenticated user from JWT token.
Usage:
@router.get("/protected")
async def protected_route(current_user: CurrentUser = Depends(get_current_user)):
return {"user_id": current_user.id}
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
token_data = decode_access_token(token)
if token_data is None:
raise credentials_exception
return CurrentUser(id=token_data.user_id, email=token_data.email)
async def get_current_user_optional( credentials: Optional[HTTPAuthorizationCredentials] = Depends( HTTPBearer(auto_error=False) ) ) -> Optional[CurrentUser]: """ Optional dependency - returns None if no valid token provided.
Usage for routes that work with or without authentication:
@router.get("/public-or-private")
async def route(current_user: Optional[CurrentUser] = Depends(get_current_user_optional)):
if current_user:
return {"authenticated": True, "user_id": current_user.id}
return {"authenticated": False}
"""
if credentials is None:
return None
token_data = decode_access_token(credentials.credentials)
if token_data is None:
return None
return CurrentUser(id=token_data.user_id, email=token_data.email)
def verify_user_access(current_user: CurrentUser, resource_user_id: str) -> None: """ Verify that the current user has access to a resource owned by resource_user_id. Raises 403 Forbidden if access is denied.
Usage:
@router.get("/users/{user_id}/tasks")
async def get_user_tasks(
user_id: str,
current_user: CurrentUser = Depends(get_current_user)
):
verify_user_access(current_user, user_id)
# ... fetch tasks
"""
if current_user.id != resource_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this resource"
)
6. Auth Schemas
Create backend/src/schemas/auth.py :
from pydantic import BaseModel, EmailStr, Field
class UserSignup(BaseModel): """Request schema for user signup.""" email: EmailStr password: str = Field(min_length=8, max_length=100) name: str = Field(default="", max_length=100)
class UserLogin(BaseModel): """Request schema for user login.""" email: EmailStr password: str
class TokenResponse(BaseModel): """Response schema for authentication.""" access_token: str token_type: str = "bearer" user: "UserResponse"
class UserResponse(BaseModel): """Response schema for user data.""" id: str email: str name: str
class Config:
from_attributes = True
class AuthError(BaseModel): """Error response for authentication failures.""" detail: str
- User Model
Create backend/src/models/user.py :
from datetime import datetime from typing import Optional from sqlmodel import Field, SQLModel import uuid
class UserBase(SQLModel): """Base user model.""" email: str = Field(unique=True, index=True, max_length=255) name: str = Field(default="", max_length=100)
class User(UserBase, table=True): """User database model.""" tablename = "users"
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
hashed_password: str
is_active: bool = Field(default=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class UserCreate(UserBase): """Schema for creating a user (internal use).""" hashed_password: str
- Auth Router
Create backend/src/routers/auth.py :
from fastapi import APIRouter, Depends, HTTPException, status from sqlmodel import Session, select
from src.database import get_session from src.models.user import User, UserCreate from src.schemas.auth import UserSignup, UserLogin, TokenResponse, UserResponse from src.utils.jwt import get_password_hash, verify_password, create_access_token from src.middleware.auth import get_current_user, CurrentUser
router = APIRouter(prefix="/api/auth", tags=["authentication"])
@router.post("/signup", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) async def signup( user_data: UserSignup, session: Session = Depends(get_session) ): """ Create a new user account.
- **email**: Valid email address (must be unique)
- **password**: Minimum 8 characters
- **name**: Optional display name
"""
# Check if user already exists
existing_user = session.exec(
select(User).where(User.email == user_data.email)
).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create new user
hashed_password = get_password_hash(user_data.password)
user = User(
email=user_data.email,
name=user_data.name or user_data.email.split("@")[0],
hashed_password=hashed_password
)
session.add(user)
session.commit()
session.refresh(user)
# Generate token
access_token = create_access_token(user_id=user.id, email=user.email)
return TokenResponse(
access_token=access_token,
user=UserResponse(id=user.id, email=user.email, name=user.name)
)
@router.post("/login", response_model=TokenResponse) async def login( credentials: UserLogin, session: Session = Depends(get_session) ): """ Authenticate user and return JWT token.
- **email**: Registered email address
- **password**: Account password
"""
# Find user
user = session.exec(
select(User).where(User.email == credentials.email)
).first()
if not user or not verify_password(credentials.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is disabled"
)
# Generate token
access_token = create_access_token(user_id=user.id, email=user.email)
return TokenResponse(
access_token=access_token,
user=UserResponse(id=user.id, email=user.email, name=user.name)
)
@router.get("/me", response_model=UserResponse) async def get_current_user_info( current_user: CurrentUser = Depends(get_current_user), session: Session = Depends(get_session) ): """ Get current authenticated user's information.
Requires valid JWT token in Authorization header.
"""
user = session.get(User, current_user.id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return UserResponse(id=user.id, email=user.email, name=user.name)
@router.post("/logout") async def logout(current_user: CurrentUser = Depends(get_current_user)): """ Logout current user.
Note: JWT tokens are stateless, so this endpoint is mainly for
client-side cleanup. The token will still be valid until expiration.
For production, consider implementing token blacklisting.
"""
return {"message": "Successfully logged out"}
9. API Client with Auth
Create frontend/src/lib/api.ts :
import { authClient } from "./auth";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
interface ApiOptions extends RequestInit { requireAuth?: boolean; }
class ApiClient { private baseUrl: string;
constructor(baseUrl: string) { this.baseUrl = baseUrl; }
private async getAuthHeaders(): Promise<HeadersInit> {
const session = await authClient.getSession();
if (session?.token) {
return {
Authorization: Bearer ${session.token},
};
}
return {};
}
async request<T>(endpoint: string, options: ApiOptions = {}): Promise<T> { const { requireAuth = true, ...fetchOptions } = options;
const headers: HeadersInit = {
"Content-Type": "application/json",
...(requireAuth ? await this.getAuthHeaders() : {}),
...fetchOptions.headers,
};
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...fetchOptions,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Request failed" }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
// Task API methods
async getTasks(userId: string) {
return this.request<Task[]>(/api/${userId}/tasks);
}
async createTask(userId: string, data: CreateTaskInput) {
return this.request<Task>(/api/${userId}/tasks, {
method: "POST",
body: JSON.stringify(data),
});
}
async updateTask(userId: string, taskId: number, data: UpdateTaskInput) {
return this.request<Task>(/api/${userId}/tasks/${taskId}, {
method: "PUT",
body: JSON.stringify(data),
});
}
async toggleTaskComplete(userId: string, taskId: number) {
return this.request<Task>(/api/${userId}/tasks/${taskId}/complete, {
method: "PATCH",
});
}
async deleteTask(userId: string, taskId: number) {
return this.request<void>(/api/${userId}/tasks/${taskId}, {
method: "DELETE",
});
}
}
export const api = new ApiClient(API_BASE_URL);
// Type definitions export interface Task { id: number; user_id: string; title: string; description?: string; completed: boolean; priority: "low" | "medium" | "high"; due_date?: string; created_at: string; updated_at: string; }
export interface CreateTaskInput { title: string; description?: string; priority?: "low" | "medium" | "high"; due_date?: string; }
export interface UpdateTaskInput { title?: string; description?: string; completed?: boolean; priority?: "low" | "medium" | "high"; due_date?: string; }
Security Best Practices
- Password Requirements
-
Minimum 8 characters
-
Use bcrypt hashing with salt
-
Never store plain text passwords
- JWT Security
-
Use strong secret key (min 32 characters)
-
Set reasonable expiration (7 days)
-
Validate token on every protected request
-
Use HTTPS in production
- User Isolation
-
Always verify user owns the resource
-
Use verify_user_access() helper
-
Never expose other users' data
- Input Validation
-
Use Pydantic for request validation
-
Sanitize all inputs
-
Limit field lengths
- Error Handling
-
Don't expose internal errors
-
Use generic error messages for auth failures
-
Log detailed errors server-side
Testing Authentication
Backend Tests
import pytest from fastapi.testclient import TestClient
def test_signup_success(client): response = client.post("/api/auth/signup", json={ "email": "test@example.com", "password": "password123", "name": "Test User" }) assert response.status_code == 201 data = response.json() assert "access_token" in data assert data["user"]["email"] == "test@example.com"
def test_login_success(client, test_user): response = client.post("/api/auth/login", json={ "email": test_user.email, "password": "password123" }) assert response.status_code == 200 assert "access_token" in response.json()
def test_protected_route_without_token(client): response = client.get("/api/test-user/tasks") assert response.status_code == 401
References
-
Better Auth Documentation
-
FastAPI Security
-
Phase 2 Constitution
-
Phase 2 Specification