DEV Community

Cover image for Understanding User Registration: Email Agent Series Part 1
Nazmus Saqueeb Ashrafi
Nazmus Saqueeb Ashrafi

Posted on

Understanding User Registration: Email Agent Series Part 1

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

Figure 1: High-level sequence diagram of the registration process

Key Steps:

  1. User Input → Form submission with validation
  2. Frontend → Three-layer architecture (UI → Context → API Client)
  3. HTTP Request → POST to backend with user data
  4. Backend Validation → Pydantic schema checks
  5. Password Security → bcrypt hashing with salt
  6. Database → User record persisted
  7. Auto-Login → Seamless authentication
  8. 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:

  1. UI Component Layer - User interaction and form state
  2. Context Layer - Global state management and business logic
  3. 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

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');
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Cleaner API: Components call useAuth() instead of useContext(AuthContext) - shorter and more semantic
  2. Error Handling: The hook throws a helpful error if used outside AuthProvider, catching mistakes early
  3. Type Safety: TypeScript knows the exact return type, providing better autocomplete
  4. Encapsulation: Implementation details hidden - we could change the underlying mechanism without affecting components
  5. 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();
Enter fullscreen mode Exit fullscreen mode

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;
  },
};
Enter fullscreen mode Exit fullscreen mode

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();
}

Enter fullscreen mode Exit fullscreen mode

// 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 

Enter fullscreen mode Exit fullscreen mode

// 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 
  });
}
Enter fullscreen mode Exit fullscreen mode

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

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"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Invalid Email Format

HTTP 422 Unprocessable Entity
{
  "detail": [
    {
      "loc": ["body", "email"],
      "msg": "value is not a valid email address",
      "type": "value_error.email"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Missing Required Field

HTTP 422 Unprocessable Entity
{
  "detail": [
    {
      "loc": ["body", "first_name"],
      "msg": "field required",
      "type": "value_error.missing"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The Auto-Login Feature

One of the UX improvements in this implementation is automatic login after registration. Here's how it works:

  1. User submits registration form
  2. Backend creates account successfully
  3. Frontend Auth Context automatically calls login() with the same credentials
  4. JWT token is retrieved and stored
  5. User state is updated globally
  6. User is redirected to /accounts dashboard

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

  1. Start Backend:
   cd webapp/backend
   uv run main.py
Enter fullscreen mode Exit fullscreen mode
  1. Start Frontend:
   cd webapp/frontend
   npm run dev
Enter fullscreen mode Exit fullscreen mode
  1. Navigate to Registration:
   http://localhost:3000/auth/register
Enter fullscreen mode Exit fullscreen mode
  1. Fill Form:

    • Email: test@example.com
    • First Name: Test
    • Last Name: User
    • Password: password123
    • Confirm Password: password123
  2. Verify:

    • Check console logs for success
    • Verify redirect to accounts page
    • Check database for new user record

Database Verification

sqlite3 webapp/backend/databse.db
Enter fullscreen mode Exit fullscreen mode
SELECT id, email, first_name, last_name, created_at 
FROM users 
WHERE email = 'test@example.com';
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)