# User Authentication with FastAPI and Next.js

- **URL:** https://isaacfei.com/posts/user-auth-fastapi-nextjs
- **Date:** 2025-08-07
- **Tags:** FastAPI, Next.js, Auth, JWT, DDD
- **Description:** Learn how to build a robust authentication system using FastAPI backend with JWT tokens, bcrypt password hashing, and Next.js frontend with cookie-based session management.

---

This post covers a complete user authentication implementation using **FastAPI** for the backend and **Next.js** for the frontend. The system uses JWT tokens, bcrypt password hashing, and HTTP-only cookie management for secure session handling.

I'll walk through the key components and explain the technical decisions behind each part.

## Architecture Overview

The authentication system follows **Domain-Driven Design (DDD)** principles with clear separation between domain logic, application services, and infrastructure concerns:

- **Backend**: FastAPI with PostgreSQL
- **Frontend**: Next.js with React Query and Zustand
- **Security**: JWT tokens with access/refresh token rotation
- **Session Management**: Secure HTTP-only cookies

```mermaid
graph TB
    subgraph "Frontend (Next.js)"
        A[Login Component]
        B[Zustand Store]
        C[React Query Hooks]
        D[API Client]
    end
    
    subgraph "Backend (FastAPI)"
        subgraph "Presentation Layer"
            E[Auth Router]
        end
        subgraph "Application Layer"
            F[Command Handlers]
            K[ITokenService Interface]
        end
        subgraph "Domain Layer"
            G[Domain Services]
            H[User Entity]
            I[Password Value Object]
        end
        subgraph "Infrastructure Layer"
            J[PostgreSQL]
            L[JWT Token Service Impl]
        end
    end
    
    A --> C
    B --> A
    C --> D
    D -->|HTTP Requests| E
    E --> F
    F --> K
    F --> G
    G --> H
    H --> I
    L -.->|implements| K
    F --> J
    G --> J
    
    E -->|Set-Cookie Headers| D
    D -->|HttpOnly Cookies| C
```

## Backend Implementation

### Domain Layer: User Entity and Value Objects

The User entity serves as the core domain object, encapsulating user data and behavior:

```python
# backend/src/aichat/domains/user/domain/entities/user.py
class User(Entity):
    def __init__(
        self,
        *,
        id: UUID,
        created_at: dt.datetime,
        updated_at: dt.datetime,
        name: str,
        email: str,
        password: Password,
        avatar_url: Optional[str] = None,
    ) -> None:
        super().__init__(
            id=id,
            created_at=created_at,
            updated_at=updated_at,
        )
        self.name = name
        self.email = email
        self.password = password
        self.avatar_url = avatar_url

    @classmethod
    def create(
        cls,
        *,
        name: str,
        email: str,
        password: Password,
        avatar_url: Optional[str] = None,
    ) -> Self:
        user = cls(
            id=cls.generate_id(),
            created_at=cls.get_current_datetime(),
            updated_at=cls.get_current_datetime(),
            name=name,
            email=email,
            password=password,
            avatar_url=avatar_url,
        )
        return user

    @property
    def info(self) -> UserInfo:
        return UserInfo(
            user_id=self.id,
            name=self.name,
            email=self.email,
            avatar_url=self.avatar_url,
        )
```

The `create` class method handles ID generation and timestamp setting automatically. The `info` property returns user data without sensitive information like passwords, providing a safe way to share user details across the application.

### Secure Password Handling

Password security uses a dedicated `Password` value object with bcrypt for hashing:

```python
# backend/src/aichat/domains/user/domain/value_objects/password.py
class Password(ValueObject):
    hashed_value: str

    @classmethod
    def from_plaintext(cls, value: str) -> Self:
        return cls(hashed_value=cls._hash(value))

    def check(self, value: str) -> bool:
        return bcrypt.checkpw(
            value.encode(),
            self.hashed_value.encode(),
        )

    @staticmethod
    def _hash(value: str) -> str:
        return bcrypt.hashpw(
            value.encode(),
            bcrypt.gensalt(),
        ).decode()
```

The `from_plaintext` method automatically hashes passwords with a unique salt via `bcrypt.gensalt()`. The `check` method performs secure password verification without exposing the original password. Bcrypt's computational expense makes brute force attacks impractical.

### Authentication Endpoints

The authentication API implements three main endpoints for the complete auth flow:

```python
# backend/src/aichat/presentation/rest/routers/auth.py
@router.post("/signup", response_model=SignupResponse)
@inject
def signup(
    request: SignupRequest,
    signup_command_handler: SignupCommandHandler = Depends(
        Provide[AppDepContainer.signup_command_handler],
    ),
):
    try:
        signup_command = SignupCommand(
            name=request.name,
            email=request.email,
            password=request.password,
        )
        signup_command_handler.handle(signup_command)
        return SignupResponse(
            status_code=status.HTTP_201_CREATED,
            message="User registered successfully",
        )
    except Exception as e:
        return SignupResponse(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            message="Internal server error",
            error=str(e),
        )

@router.post("/login", response_model=LoginResponse)
@inject
def login(
    response: Response,
    request: LoginRequest,
    login_command_handler: LoginCommandHandler = Depends(
        Provide[AppDepContainer.login_command_handler],
    ),
):
    try:
        login_command = LoginCommand(
            email=request.email,
            password=request.password,
        )
        access_token, refresh_token = login_command_handler.handle(login_command)
        
        # Set secure HTTP-only cookies
        response.set_cookie(
            key=ACCESS_TOKEN_COOKIE_KEY,
            value=access_token,
            httponly=True,
            secure=True,
        )
        response.set_cookie(
            key=REFRESH_TOKEN_COOKIE_KEY,
            value=refresh_token,
            httponly=True,
            secure=True,
        )
        
        return LoginResponse(
            status_code=status.HTTP_200_OK,
            message="User logged in successfully",
        )
    except Exception as e:
        return LoginResponse(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            message="Internal server error",
            error=str(e),
        )
```

The `response.set_cookie()` calls instruct FastAPI to add `Set-Cookie` headers to the HTTP response. The browser receives these headers and stores the cookies with the specified security attributes.

Cookie settings breakdown:

- `httponly=True` - Prevents JavaScript access, protecting against XSS attacks
- `secure=True` - Ensures cookies only transmit over HTTPS connections
- The browser automatically includes these cookies in subsequent requests

After successful login, the browser's developer tools will show two cookies with token values, marked as HttpOnly and Secure:

![Browser developer tools showing access-token and refresh-token cookies with HttpOnly and Secure flags set.](./browser-console-screenshot.jpg)

The complete authentication flow works as follows:

```mermaid
sequenceDiagram
    participant Browser
    participant FastAPI
    participant Database
    participant TokenService
    
    Browser->>FastAPI: POST /auth/login<br/>{email, password}
    FastAPI->>Database: Find user by email
    Database-->>FastAPI: User entity
    FastAPI->>FastAPI: Verify password with bcrypt
    FastAPI->>TokenService: Create access token
    TokenService-->>FastAPI: JWT access token
    FastAPI->>TokenService: Create refresh token
    TokenService-->>FastAPI: Refresh token string
    FastAPI->>Database: Save refresh token
    FastAPI->>Browser: Set-Cookie: access-token (HttpOnly, Secure)
    FastAPI->>Browser: Set-Cookie: refresh-token (HttpOnly, Secure)
    FastAPI-->>Browser: 200 OK Login Success
    
    Note over Browser,Database: Subsequent authenticated requests
    Browser->>FastAPI: GET /auth/current-user<br/>(cookies automatically sent)
    FastAPI->>TokenService: Verify access token
    TokenService-->>FastAPI: User info from token
    FastAPI-->>Browser: User data
```

### JWT Token Management

The login command handler generates both access and refresh tokens:

```python
# backend/src/aichat/domains/user/application/commands/login_command_handler.py
class LoginCommandHandler:
    def handle(self, command: LoginCommand) -> tuple[str, str]:
        with self._unit_of_work as unit_of_work:
            # Find and validate user
            user_repository: IUserRepository = unit_of_work.get_repository(IUserRepository)
            user = user_repository.find_user_by_email(command.email)
            
            if user is None:
                raise ValueError(f"User with email {command.email} does not exist")
            
            if not user.password.check(command.password):
                raise ValueError("Invalid password")
            
            # Generate tokens
            access_token = self._token_service.create_access_token(
                user_info=user.info,
            )
            refresh_token_value = self._token_service.create_refresh_token(
                user_id=user.id,
            )
            
            # Store refresh token
            refresh_token = RefreshToken.create(
                user_id=user.id,
                value=refresh_token_value,
            )
            refresh_token_repository: IRefreshTokenRepository = (
                unit_of_work.get_repository(IRefreshTokenRepository)
            )
            refresh_token_repository.save_refresh_token(refresh_token)
            
        return access_token, refresh_token_value
```

This design uses two token types: access tokens contain user information with short lifespans (15-30 minutes), while refresh tokens are random strings stored server-side with longer lifespans (7 days).

The dual-token approach balances security and usability. Stolen access tokens expire quickly, while refresh tokens enable seamless token renewal without frequent logins. Server-side refresh token storage allows for immediate revocation when needed.

```mermaid
graph LR
    subgraph "Token Types"
        A[Access Token<br/>• Contains user info<br/>• Short lifespan: 15-30 min<br/>• Used for API requests]
        B[Refresh Token<br/>• Random string<br/>• Long lifespan: 7 days<br/>• Stored server-side<br/>• Used for token renewal]
    end
    
    subgraph "Security Benefits"
        C[Limited Exposure<br/>Access tokens expire quickly]
        D[Revocation Control<br/>Server can invalidate refresh tokens]
        E[User Experience<br/>No frequent re-login required]
    end
    
    A --> C
    B --> D
    B --> E
    
    subgraph "Storage Locations"
        F[Browser Cookie<br/>HttpOnly + Secure]
        G[Database<br/>Refresh tokens table]
    end
    
    A --> F
    B --> F
    B --> G
```

### Authentication Middleware

The authentication dependency extracts and validates tokens from protected route requests:

```python
# backend/src/aichat/presentation/rest/dependencies.py
@inject
def get_current_user_info(
    request: Request,
    token_service: ITokenService = Depends(Provide[AppDepContainer.token_service]),
) -> UserInfoDTO:
    # Check Authorization header first
    access_token = request.headers.get("Authorization")
    if access_token is not None:
        access_token = access_token.removeprefix("Bearer ")
    # Fall back to cookies
    else:
        access_token = request.cookies.get(ACCESS_TOKEN_COOKIE_KEY)
        if access_token is None:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Unauthorized",
            )
    
    try:
        # Verify token and extract user info
        user_info = token_service.verify_access_token(access_token)
        return UserInfoDTO(
            user_id=user_info.user_id,
            name=user_info.name,
            email=user_info.email,
            avatar_url=user_info.avatar_url,
        )
    except Exception as e:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=f"Unauthorized: {e}",
        )
```

This implementation supports dual authentication methods: Bearer tokens in Authorization headers (for API clients) and cookie-based authentication (for browsers). The fallback mechanism enables seamless compatibility across different client types.

## Frontend Implementation

### State Management with Zustand

The frontend uses Zustand for lightweight state management with minimal boilerplate:

```typescript
// frontend/src/stores/user-info-store.ts
interface State {
  userInfo: UserInfo | null;
  isSignedIn: boolean;
}

interface Action {
  setUserInfo: (userInfo: UserInfo) => void;
  setIsSignedIn: (isSignedIn: boolean) => void;
  clearUserInfo: () => void;
}

const _useUserInfoStore = create<State & Action>()(
  persist(
    (set) => ({
      userInfo: null,
      isSignedIn: false,
      setUserInfo: (userInfo) => set({ userInfo }),
      setIsSignedIn: (isSignedIn) => set({ isSignedIn }),
      clearUserInfo: () => set({ userInfo: null, isSignedIn: false }),
    }),
    {
      name: "user-info-storage",
      // Only persist the isSignedIn state for security
      partialize: (state) => ({ isSignedIn: state.isSignedIn }),
    },
  ),
);
```

The `partialize` configuration only persists the `isSignedIn` boolean to localStorage, excluding sensitive user data. When the app loads with `isSignedIn: true`, it fetches fresh user data from the server. This approach maintains persistent login state while avoiding security risks from storing sensitive data in localStorage.

```mermaid
graph LR
    subgraph "Browser Storage Strategy"
        A[localStorage]
        B[Memory]
        C[HTTP-Only Cookies]
    end
    
    subgraph "Data Types"
        D[isSignedIn: boolean<br/>✓ Safe to persist]
        E[User Info Object<br/>✗ Sensitive data]
        F[JWT Tokens<br/>✓ Secure in cookies]
    end
    
    subgraph "Security Benefits"
        G[Persistent Login State]
        H[Fresh Data on Each Session]
        I[XSS Protection]
    end
    
    D --> A
    E --> B
    F --> C
    
    A --> G
    B --> H
    C --> I
    
    subgraph "App Load Process"
        J[Check localStorage<br/>isSignedIn: true?]
        K[Fetch User Data<br/>from Server]
        L[Update Zustand Store]
    end
    
    J --> K
    K --> L
```

### API Integration with React Query

Custom hooks handle API integration with automatic data transformation and validation:

```typescript
// frontend/src/hooks/api/use-login.ts
export const useLogin = () => {
  return useMutation({
    mutationFn: async (loginData: LoginRequest): Promise<LoginResponse> => {
      // Convert camelCase to snake_case for backend
      const backendPayload = convertCamelToSnake(loginData);
      
      const { data: backendResponse } = await apiClient.post(
        "/api/auth/login",
        backendPayload,
      );
      
      // Convert and validate response
      const response: LoginResponse = LoginResponseSchema.parse(
        convertSnakeToCamel(backendResponse),
      );
      
      return response;
    },
  });
};

export const useGetCurrentUserInfo = (options?: UseGetCurrentUserInfoOptions) => {
  return useQuery({
    queryKey: ["current-user-info"],
    queryFn: async () => {
      const { data: backendResponse } = await apiClient.get(
        "/api/auth/current-user",
      );
      
      const response: GetCurrentUserInfoResponse =
        GetCurrentUserInfoResponseSchema.parse(
          convertSnakeToCamel(backendResponse),
        );
      
      return response.data;
    },
    enabled: options?.enabled ?? true,
  });
};
```

The hooks automatically convert between camelCase (frontend) and snake\_case (backend) conventions. Zod schemas validate API responses, catching unexpected data structures before they cause runtime errors.

### Login Component

The login component demonstrates the integration between UI and authentication logic:

```typescript
// frontend/src/app/(auth)/login/page.tsx
export default function LoginPage() {
  const router = useRouter();
  const setIsSignedIn = useUserInfoStore.use.setIsSignedIn();
  const loginMutation = useLogin();

  const handleLogin = async (data: LoginData) => {
    try {
      const response = await loginMutation.mutateAsync(data);
      
      if (response.statusCode === 200) {
        setIsSignedIn(true);
        toast.success("Welcome back! You're now logged in.");
        router.push("/");
      } else {
        const errorMessage = response.error || response.message || "Login failed. Please try again.";
        toast.error(errorMessage);
      }
    } catch (error) {
      console.error("Login error:", error);
      toast.error("Something went wrong. Please check your connection and try again.");
    }
  };

  return (
    <div className="flex min-h-screen items-center justify-center px-4 py-12">
      <div className="w-full max-w-md space-y-8">
        <div className="text-center">
          <h1 className="text-3xl font-bold text-gray-900 dark:text-white">
            Welcome back
          </h1>
          <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
            Sign in to your account to continue
          </p>
        </div>
        <LoginForm onSubmit={handleLogin} isLoading={loginMutation.isPending} />
      </div>
    </div>
  );
}
```

The `handleLogin` function manages the complete login flow: API calls, state updates on success, error handling, and user feedback. The `loginMutation.isPending` state automatically handles form loading states during submission.

## Security Features

### HTTP-Only Cookies

HTTP-only cookies provide XSS protection by preventing JavaScript access:

```python
# backend/src/aichat/presentation/rest/routers/auth.py
response.set_cookie(
    key=ACCESS_TOKEN_COOKIE_KEY,
    value=access_token,
    httponly=True,    # Prevents JavaScript access
    secure=True,      # HTTPS only
)
```

The `httponly=True` flag prevents malicious JavaScript from accessing authentication cookies, even during XSS attacks. The `secure=True` flag ensures cookies only transmit over HTTPS connections. Browser dev tools display these cookies with security flags indicating proper protection.

### Password Security

- **Bcrypt hashing** with automatic salt generation
- **Password validation** happens in the domain layer
- **No plain text storage** anywhere in the system

### Token Management

- **Access tokens** for short-term authentication
- **Refresh tokens** stored server-side for revocation control
- **Dual authentication** support (Bearer tokens + cookies)

## Conclusion

This authentication system demonstrates several key techniques:

- **Secure password handling** using bcrypt with automatic salt generation
- **JWT token management** with access/refresh token separation for security and usability
- **Domain-driven architecture** with clear separation of concerns
- **Flexible authentication** supporting both API clients and web browsers
- **Type-safe integration** with comprehensive error handling

The modular design supports future extensions like two-factor authentication, social login, or mobile app integration through existing Bearer token support.