Claude-skill-registry better-auth-integration
Integrate Better Auth for JWT-based authentication in Next.js frontend and FastAPI backend. Handles signup, login, logout, token management, and protected routes. Use when implementing authentication for Phase 2.
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/better-auth-integration" ~/.claude/skills/majiayu000-claude-skill-registry-better-auth-integration && rm -rf "$T"
manifest:
skills/data/better-auth-integration/SKILL.mdsource content
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)
1. Install Dependencies
cd frontend npm install better-auth @better-auth/client
2. 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
3. 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;
4. 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; }
5. 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"], };
6. 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> ); }
7. 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)
1. Install Dependencies
cd backend uv add python-jose[cryptography] passlib[bcrypt] pydantic-settings
2. 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
3. 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()
4. 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
7. 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
8. 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
1. Password Requirements
- Minimum 8 characters
- Use bcrypt hashing with salt
- Never store plain text passwords
2. JWT Security
- Use strong secret key (min 32 characters)
- Set reasonable expiration (7 days)
- Validate token on every protected request
- Use HTTPS in production
3. User Isolation
- Always verify user owns the resource
- Use
helperverify_user_access() - Never expose other users' data
4. Input Validation
- Use Pydantic for request validation
- Sanitize all inputs
- Limit field lengths
5. 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