A deep dive into building secure, scalable user registration with React, FastAPI, and modern authentication patterns
Introduction
User registration is the gateway to any application. It's the first interaction users have with your system, and it sets the tone for security, user experience, and architectural quality. In this article, I'll walk you through the complete user registration flow in my AI Email Assistant application, from the moment a user clicks "Register" to when their encrypted credentials are safely stored in the database.
We'll explore:
- Frontend architecture with React Context and custom hooks
- Backend API design with FastAPI and Pydantic validation
- Security best practices including bcrypt password hashing
- Clean architecture patterns that promote maintainability and testability
System Overview
The registration flow spans multiple layers of abstraction, each with a specific responsibility:
Frontend Stack:
- Next.js 14 (React with TypeScript)
- React Context for global state management
- Custom hooks for clean component APIs
- Layered API client architecture
Backend Stack:
- FastAPI (Python)
- SQLAlchemy ORM
- Pydantic for data validation
- bcrypt for password hashing
- JWT for authentication tokens
The Complete Flow at a Glance
Before diving into the details, let's visualize the high-level journey:
Figure 1: High-level sequence diagram of the registration process
Key Steps:
- User Input → Form submission with validation
- Frontend → Three-layer architecture (UI → Context → API Client)
- HTTP Request → POST to backend with user data
- Backend Validation → Pydantic schema checks
- Password Security → bcrypt hashing with salt
- Database → User record persisted
- Auto-Login → Seamless authentication
- Redirect → User sent to dashboard
Part 1: Frontend Architecture
The Three-Layer Abstraction
One of the key architectural decisions in this application is the separation of concerns on the frontend. Instead of making direct API calls from UI components, the registration flow passes through three distinct layers:
- UI Component Layer - User interaction and form state
- Context Layer - Global state management and business logic
- API Client Layer - HTTP communication and token management
This architecture provides several benefits:
- Reusability: Auth functions available throughout the app
- Testability: Each layer can be tested in isolation
- Maintainability: Changes to API structure only affect one file
- Type Safety: TypeScript ensures correctness across layers
Frontend Flow Diagram
Figure 2: Frontend architecture showing the flow from UI component through Context and API clients to the backend
Layer 1: The Registration Page Component
The registration page is a client-side React component that manages form state and user interaction.
// webapp/frontend/app/auth/register/page.tsx
export default function RegisterPage() {
const router = useRouter();
const { register } = useAuth(); // Custom hook from AuthContext
const [formData, setFormData] = useState({
email: '',
first_name: '',
last_name: '',
password: '',
confirmPassword: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Client-side validation
const validationError = validateForm();
if (validationError) {
setError(validationError);
return;
}
try {
// Call register from context - clean and simple!
await register({
email: formData.email,
first_name: formData.first_name,
last_name: formData.last_name,
password: formData.password,
});
// Auto-login successful, redirect to dashboard
router.push('/accounts');
} catch (err: any) {
setError(err.message || 'Registration failed');
}
};
}
Key Observations:
- No HTTP details in the component - it just calls
register() - Clean separation between UI logic and API communication
- Error handling is straightforward and user-friendly
Layer 2: Authentication Context Provider
The Auth Context is where the magic happens. It's a React Context Provider that manages global authentication state and provides auth functions to all components.
// webapp/frontend/components/auth/auth-context.tsx
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
/**
* Register new user and auto-login
*/
const register = useCallback(async (data: RegisterData) => {
setIsLoading(true);
try {
// Step 1: Register user
await authClient.register(data);
// Step 2: Auto-login after successful registration
await login({ email: data.email, password: data.password });
} catch (error) {
setIsLoading(false);
throw error;
}
}, [login]);
return (
<AuthContext.Provider value={{ user, register, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// Custom hook for easy access
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
Why Use Context?
- Global State: Authentication state available everywhere in the app (ie. Shared data across multiple components)
- Avoid Prop Drilling: No need to pass auth functions through multiple components
- Single Source of Truth: One place manages all authentication logic
- Auto-Login Feature: Seamless UX - users are logged in immediately after registration
Why Wrap Context in a Custom Hook (useAuth)?
The useAuth() custom hook is a convenience wrapper around useContext(AuthContext). It has the CONSUMER which can consume the AuthContext PROVIDER. While we could use useContext(AuthContext) directly in components, the custom hook provides several advantages:
-
Cleaner API: Components call
useAuth()instead ofuseContext(AuthContext)- shorter and more semantic -
Error Handling: The hook throws a helpful error if used outside
AuthProvider, catching mistakes early - Type Safety: TypeScript knows the exact return type, providing better autocomplete
- Encapsulation: Implementation details hidden - we could change the underlying mechanism without affecting components
-
Consistency: Standard React pattern - most Context APIs provide custom hooks (e.g.,
useRouter,useTheme)
Example:
// Without custom hook (verbose, no error checking)
const context = useContext(AuthContext);
if (!context) throw new Error('...');
const { register } = context;
// With custom hook (clean, safe)
const { register } = useAuth();
This pattern is so common in React that it's considered a best practice for any Context API.
Layer 3: API Client Architecture
The API client layer is split into two files:
auth-client.ts - Authentication-specific API calls:
export const authClient = {
register: async (data: RegisterData): Promise<void> => {
await api.post('/api/auth/register', data);
},
login: async (credentials: LoginCredentials): Promise<AuthTokens> => {
// OAuth2 password flow
const formData = new URLSearchParams();
formData.append('username', credentials.email);
formData.append('password', credentials.password);
const response = await fetch(/* ... */);
const tokens = await response.json();
// Store JWT token
tokenManager.set(tokens.access_token);
return tokens;
},
};
api-client.ts - Base HTTP client with automatic token injection:
// BLOCK 1
export async function apiClient<T>(
endpoint: string,
options: RequestOptions = {}
): Promise<T> {
const config: RequestInit = {
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
};
// Auto-inject JWT token for protected endpoints
if (options.requiresAuth) {
const token = getAuthToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
const response = await fetch(`${API_BASE_URL}${endpoint}`, config);
// Centralized error handling
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Request failed');
}
return response.json();
}
// BLOCK 2
// ❌ WITHOUT apiClient - Repetitive nightmare
async function getUser() {
const token = localStorage.getItem('auth_token');
const response = await fetch('http://localhost:8000/api/users/me', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Request failed');
}
return response.json();
}
async function updateUser(data) {
const token = localStorage.getItem('auth_token');
const response = await fetch('http://localhost:8000/api/users/me', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Request failed');
}
return response.json();
}
// ... repeat this 50 times for every endpoint
// BLOCK 3
// ✅ WITH apiClient - Clean and reusable
async function getUser() {
return apiClient('/api/users/me', { requiresAuth: true });
}
async function updateUser(data) {
return apiClient('/api/users/me', {
method: 'PUT',
requiresAuth: true,
body: data
});
}
Benefits of using the apiClient:
- DRY (Do not repeat yourself) Principle: All API calls use the same base client - See code BLOCK 2 and 3 above
- Automatic Token Injection: Protected endpoints get JWT automatically - See code BLOCK 1 above
- Centralized Error Handling: Consistent error messages across the app - See code BLOCK 1 above
- Type Safety: TypeScript generics ensure type correctness - See code BLOCK 1 above
Part 2: Backend Architecture
The Service Layer Pattern
The backend follows a clean Router → Service → Database pattern:
- Router: HTTP layer - handles requests and responses
- Service: Business logic - validation, password hashing, database operations
- Database: Data persistence - SQLAlchemy models
This separation ensures that business logic is decoupled from HTTP concerns, making it easier to test and maintain.
Backend Flow Diagram
Figure 3: Backend architecture showing the flow from API endpoint through validation, service layer, and database operations
The Router: API Endpoint Definition
The router is the entry point for HTTP requests. It defines the endpoint and delegates to the service layer.
# webapp/backend/auth/router.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
router = APIRouter(prefix='/auth', tags=['auth'])
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register_user(
db: Annotated[Session, Depends(get_db)],
register_user_request: schemas.RegisterUserRequest
):
"""
Register a new user
- **email**: Valid email address (must be unique)
- **first_name**: User's first name
- **last_name**: User's last name
- **password**: User's password (will be hashed)
"""
service.register_user(db, register_user_request)
return {"message": "User registered successfully"}
Key Features:
- Dependency Injection: Database session injected automatically
- Pydantic Validation: Request body validated against schema
- Status Code: Returns 201 Created (REST convention)
- Clean Separation: Router handles HTTP, service handles logic
Pydantic Schema: Request Validation
Pydantic schemas define the shape and validation rules for request data.
# webapp/backend/auth/schemas.py
from pydantic import BaseModel, EmailStr
class RegisterUserRequest(BaseModel):
"""Request model for user registration"""
email: EmailStr
first_name: str
last_name: str
password: str
What Pydantic Does:
-
Email Validation: Checks format (e.g.,
user@example.com) - Type Checking: Ensures all fields are correct types
- Automatic Parsing: Converts JSON to Python objects
- Error Messages: Returns detailed validation errors
If validation fails, FastAPI automatically returns a 422 Unprocessable Entity response with details about what went wrong.
Service Layer: Business Logic
The service layer contains the core registration logic, including password hashing and database operations.
# webapp/backend/auth/service.py
from passlib.context import CryptContext
from uuid import uuid4
bcrypt_context = CryptContext(schemes=['bcrypt'], deprecated='auto')
def get_password_hash(password: str) -> str:
"""Hash a plain password using bcrypt"""
return bcrypt_context.hash(password)
def register_user(db: Session, register_user_request: schemas.RegisterUserRequest):
"""
Register a new user
Raises UserAlreadyExistsError if email is already registered
"""
try:
# Create User model with hashed password
create_user_model = User(
id=uuid4(), # Generate unique UUID
email=register_user_request.email,
first_name=register_user_request.first_name,
last_name=register_user_request.last_name,
password_hash=get_password_hash(register_user_request.password)
)
# Add to database and commit
db.add(create_user_model)
db.commit()
logging.info(f"Successfully registered user: {register_user_request.email}")
except IntegrityError:
# Email already exists (UNIQUE constraint violation)
db.rollback()
raise UserAlreadyExistsError()
Critical Security Feature: bcrypt Password Hashing
The password is never stored in plain text. Instead, it's hashed using bcrypt:
Input: "securepass123"
Output: "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYzS7eFa.oi"
Why bcrypt?
- Salted: Automatically adds random salt (prevents rainbow table attacks)
- Adaptive: Can increase cost factor as computers get faster
- One-way: Cannot reverse hash to get original password
- Industry Standard: Used by GitHub, Facebook, and other major platforms
Database Model: SQLAlchemy Schema
The User model defines the database table structure.
# webapp/backend/entities/users.py
from sqlalchemy import Column, String, DateTime, UUID
from datetime import datetime, timezone
class User(Base):
__tablename__ = 'users'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String, unique=True, nullable=False)
first_name = Column(String, nullable=False)
last_name = Column(String, nullable=False)
password_hash = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), onupdate=lambda: datetime.now(timezone.utc))
# Relationships
email_accounts = relationship("EmailAccount", back_populates="user", cascade="all, delete-orphan")
Database Constraints:
- Primary Key: UUID (globally unique, secure)
- Unique Email: Database enforces uniqueness
- Not Null: All required fields enforced at DB level
- Cascade Delete: Deleting user deletes all related email accounts
Security Considerations
Defense in Depth
Security is implemented at multiple layers:
1. Frontend Validation
- Client-side checks for immediate feedback
- Password length requirements (minimum 8 characters)
- Password confirmation matching
2. Backend Validation
- Pydantic schema validation (type checking, email format)
- Database constraints (unique email, not null)
3. Password Security
- bcrypt hashing with automatic salting
- Cost factor of 12 (2^12 = 4096 iterations)
- Never logged or displayed in plain text
4. SQL Injection Prevention
- SQLAlchemy ORM automatically parameterizes queries
- No raw SQL with user input
5. Token Security
- JWT tokens for stateless authentication
- Tokens stored in localStorage (HTTPS in production)
- Automatic expiration (30 days)
Error Handling
The system handles errors gracefully at every layer:
Email Already Exists
HTTP 400 Bad Request
{
"detail": "User with this email already exists"
}
Invalid Email Format
HTTP 422 Unprocessable Entity
{
"detail": [
{
"loc": ["body", "email"],
"msg": "value is not a valid email address",
"type": "value_error.email"
}
]
}
Missing Required Field
HTTP 422 Unprocessable Entity
{
"detail": [
{
"loc": ["body", "first_name"],
"msg": "field required",
"type": "value_error.missing"
}
]
}
The Auto-Login Feature
One of the UX improvements in this implementation is automatic login after registration. Here's how it works:
- User submits registration form
- Backend creates account successfully
- Frontend Auth Context automatically calls
login()with the same credentials - JWT token is retrieved and stored
- User state is updated globally
- User is redirected to
/accountsdashboard
Benefits:
- Seamless UX: No need to manually log in after registering
- Fewer Steps: Reduces friction in onboarding flow
- Immediate Value: User can start using the app right away
Testing the Flow
Manual Testing Steps
- Start Backend:
cd webapp/backend
uv run main.py
- Start Frontend:
cd webapp/frontend
npm run dev
- Navigate to Registration:
http://localhost:3000/auth/register
-
Fill Form:
- Email:
test@example.com - First Name:
Test - Last Name:
User - Password:
password123 - Confirm Password:
password123
- Email:
-
Verify:
- Check console logs for success
- Verify redirect to accounts page
- Check database for new user record
Database Verification
sqlite3 webapp/backend/databse.db
SELECT id, email, first_name, last_name, created_at
FROM users
WHERE email = 'test@example.com';
Performance Considerations
Password Hashing Cost
The bcrypt cost factor is set to 12, which provides a good balance:
- Cost 10: ~100ms (too fast, less secure)
- Cost 12: ~250ms (current setting, recommended)
- Cost 14: ~1000ms (too slow for user experience)
Database Indexing
The email field has a unique index for fast lookups:
CREATE UNIQUE INDEX idx_users_email ON users(email);
This ensures O(log n) search time for email lookups during login.
Lessons Learned
What Worked Well
✅ Layered Architecture: Clean separation of concerns made the code maintainable and testable
✅ React Context: Global auth state eliminated prop drilling and simplified components
✅ Pydantic Validation: Automatic validation saved time and caught errors early
✅ bcrypt Hashing: Industry-standard security with minimal implementation effort
✅ Auto-Login: Improved UX significantly with minimal code
What Could Be Improved
🔄 Add Unit Tests: Currently no automated tests for registration flow
🔄 Rate Limiting: Prevent brute-force registration attempts
🔄 Email Verification: Send confirmation email before activating account
🔄 Password Strength Meter: Visual feedback on password quality
🔄 CAPTCHA: Prevent automated bot registrations
Conclusion
Building a secure, user-friendly registration flow requires attention to detail at every layer of the stack. By following clean architecture principles, implementing security best practices, and focusing on user experience, we've created a registration system that is:
- Secure: bcrypt hashing, SQL injection prevention, multiple validation layers
- Maintainable: Clear separation of concerns, type safety, reusable components
- User-Friendly: Auto-login, clear error messages, responsive validation
- Scalable: Stateless JWT authentication, efficient database queries
The key takeaway is that good architecture pays dividends. The three-layer frontend abstraction and the router-service-database pattern on the backend make the code easy to understand, test, and extend.
Whether you're building a simple side project or a production application, these patterns and practices will serve you well.
Code Repository
The complete source code for this implementation is available in my AI Email Coach project:
-
Frontend:
webapp/frontend/app/auth/register/ -
Backend:
webapp/backend/auth/ -
Database Models:
webapp/backend/entities/users.py
Written by Nazmus Ashrafi | Built with Next.js, FastAPI, and ❤️
Full Source: Available in private repository upon request
Please contact me for access: nazmus.s.ashrafi@gmail.com



Top comments (0)