Building Agent Interfaces III: Authentication Foundations for Production Apps

Author
Fei Wang
Published
Affiliations
The AI Engineer

Almost every production application requires authentication—users log in, sessions persist, routes protect sensitive data. This guide builds authentication from foundational concepts: OAuth 2.0 flows (why Google login works), JWT tokens (stateless sessions), and coordinated frontend-backend state management. Three progressive layers: understanding SSO and OAuth architecture, implementing backend token generation and validation with FastAPI, and managing authentication state in React with protected routes and automatic token refresh. By mastering these patterns, you'll implement secure authentication for any application, not just agent interfaces.

Agent Interface I covered React’s mental model and design systems. Agent Interface II covered functional programming, state management, and API integration. This article tackles authentication—the essential infrastructure almost every production application needs.

You’ve built beautiful interfaces and mastered data flow. Now your users need to log in, their sessions need to persist, and certain routes need protection. This article teaches authentication from first principles: how OAuth 2.0 enables “Sign in with Google,” how JWT tokens create stateless sessions, and how frontend and backend coordinate to maintain user state across page refreshes and API calls.

Authentication Architecture

Authentication verifies identity—users prove who they are, your system issues credentials, and subsequent requests use those credentials for access. Two architectural approaches dominate: session-based (server stores state) and token-based (stateless validation with JWT). Modern APIs favor JWT because it scales horizontally and enables frontend-backend separation.

Email and password authentication is the foundation. Understanding the three-phase flow (registration → login → authenticated requests) is essential before exploring OAuth or SSO methods.

Three Authentication Phases: Registration (create credentials) → Login (verify identity, issue token) → Authenticated Requests (validate token on each call).

Email/Password Foundation: Users submit email + password. Backend hashes password with bcrypt, stores in database. Never store plaintext passwords.

Token-Based Flow: After login, backend issues JWT tokens (access + refresh). Frontend stores tokens and sends access token with every API request in Authorization header.

SSO Extension: Google and Microsoft follow similar pattern with OAuth 2.0 authorization code exchange (covered in future sections). Core concepts remain the same.

Click to enlarge and zoom

Choosing between session-based and token-based authentication affects your entire architecture. Session-based stores state on the server (simple invalidation, requires session store). Token-based encodes state in JWT (stateless, scales horizontally). Most modern APIs choose JWT.

Feature Session-Based JWT Token-Based
State Server stores session data Stateless (token holds claims)
Lookup Session store query on every request No lookup, validate signature only
Invalidation Easy (delete session from store) Hard (can't revoke until expiry)
Scalability Requires sticky sessions or shared store Scales horizontally (no shared state)
Payload Size Small (session ID cookie ~20 bytes) Larger (full token ~200-500 bytes)
Cross-Domain Limited (same-origin cookies) Works across domains
Use Case Traditional web apps (server-rendered) SPAs, mobile apps, microservices

When to Use Sessions: Traditional server-rendered apps (Django, Rails, Laravel). Need immediate logout/invalidation. Single-server or tightly coupled architecture.

When to Use JWT: API-first architecture (React + FastAPI, Vue + Django REST). Microservices needing stateless authentication. Mobile apps or third-party integrations. Need to scale horizontally without shared session store.

Hybrid Approach: Some systems use both—JWT for API access, sessions for admin panels. The key is understanding the architectural trade-offs and choosing based on your requirements.

JWT tokens are self-contained credentials that eliminate server-side session lookups. Understanding the three-part structure (Header.Payload.Signature) and validation process is critical for implementing secure authentication.

JWT Structure: Three Base64-encoded parts separated by dots—Header (algorithm), Payload (user claims), Signature (HMAC hash preventing tampering).

Click to enlarge and zoom

Validation Process: (1) Split token by dots, (2) Recompute signature using header + payload + SECRET_KEY, (3) Compare signatures (constant-time to prevent timing attacks), (4) Check expiration timestamp, (5) If valid, decode payload and trust claims.

Security Guarantees: Integrity (signature proves no modification). Expiration (stolen tokens expire automatically). Stateless (no database lookup needed).

Important: JWTs are encoded (Base64), not encrypted. Anyone can decode the payload. Never store sensitive data in claims. Use HTTPS to prevent token interception.

Decoded JWT Structure

Header
{
  "alg": "HS256",  // HMAC SHA-256 algorithm
  "typ": "JWT"     // Token type
}
Payload (Claims)
{
  "user_id": 123,
  "email": "[email protected]",
  "exp": 1700000000,  // Expiration (Unix timestamp)
  "iat": 1699999000,  // Issued at (Unix timestamp)
  "sub": "user:123"   // Subject (optional)
}
Signature
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  SECRET_KEY  // Server-side secret (NEVER exposed to clients)
)

The access + refresh token pattern balances security with user experience. Short-lived access tokens (15 minutes) limit exposure if stolen. Long-lived refresh tokens (7 days) prevent constant re-authentication.

Access + Refresh Pattern: Access tokens expire quickly (15 minutes) to limit damage if stolen. Refresh tokens live longer (7 days) and obtain new access tokens without re-login.

Key Lifecycle Stages: (1) Login issues both tokens, (2) Normal requests use access token, (3) Access token expires → 401 error, (4) Frontend sends refresh token automatically, (5) Backend issues new tokens with rotation, (6) Retry request seamlessly.

Token Rotation Security: Each refresh issues NEW tokens and invalidates old ones. If a stolen refresh token is reused, backend detects the anomaly and revokes all tokens for that user.

Storage Recommendations: Access token in memory or sessionStorage (short-lived). Refresh token in httpOnly cookie (prevents XSS). Never use localStorage for refresh tokens. Logout deletes tokens and calls /auth/logout to blacklist.

Click to enlarge and zoom

Frontend: Auth State & Protected Routes

Authentication state lives in global storage—users log in once, their identity persists across page refreshes and component navigation. Article II taught global state management with Zustand and persistent storage with localStorage. Authentication combines both patterns: Zustand stores user state reactively (user profile, tokens, loading status), and the persist middleware saves tokens to localStorage for session continuity.

Four implementation challenges: storing user + tokens in Zustand with persistence, protecting routes that require authentication, handling login/logout flows with navigation, and automatically refreshing expired tokens. The following tabs build these patterns progressively, starting with the auth store foundation.

Zustand eliminates prop drilling and provides reactive subscriptions. The persist middleware (taught in Article II’s Caching tab) automatically syncs state to localStorage. Combined, they create an auth store that survives page refreshes while keeping components synchronized.

Global State + Persistence: Authentication state needs to persist across page refreshes and be accessible throughout your app. [Article II](/agents/agent-interface-execution) taught global state management with Zustand—authentication uses the same pattern with the `persist` middleware for token storage.

What to Store: User profile (name, email, user_id), access token (sent with API requests), refresh token (for obtaining new access tokens), loading state (during login/logout), and error state (authentication failures).

Why Zustand: No prop drilling (any component can access auth state), reactive subscriptions (components re-render only when subscribed values change), persist middleware (automatic localStorage sync), and async actions (login/logout directly in store).

Selector Pattern: Use selectors to prevent unnecessary re-renders. Subscribe to specific values (`useAuthStore(state => state.user)`) instead of entire store. This follows the shallow equality pattern from Article II's Advanced State Management tab.

Zustand Auth Store with Persistence Store
// stores/authStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface User {
  id: number
  email: string
  name: string
}

interface AuthState {
  user: User | null
  access_token: string | null
  refresh_token: string | null
  loading: boolean
  error: string | null

  login: (email: string, password: string) => Promise<void>
  register: (email: string, password: string, name: string) => Promise<void>
  logout: () => void
  setUser: (user: User) => void
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set, get) => ({
      user: null,
      access_token: null,
      refresh_token: null,
      loading: false,
      error: null,

      login: async (email, password) => {
        set({ loading: true, error: null })
        try {
          const response = await fetch('/api/v1/auth/login', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ email, password })
          })

          if (!response.ok) {
            throw new Error('Invalid credentials')
          }

          const data = await response.json()
          set({
            user: data.user,
            access_token: data.access_token,
            refresh_token: data.refresh_token,
            loading: false
          })
        } catch (error) {
          set({
            error: error instanceof Error ? error.message : 'Login failed',
            loading: false
          })
        }
      },

      register: async (email, password, name) => {
        set({ loading: true, error: null })
        try {
          const response = await fetch('/api/v1/auth/register', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ email, password, name })
          })

          if (!response.ok) {
            throw new Error('Registration failed')
          }

          const data = await response.json()
          set({
            user: data.user,
            access_token: data.access_token,
            refresh_token: data.refresh_token,
            loading: false
          })
        } catch (error) {
          set({
            error: error instanceof Error ? error.message : 'Registration failed',
            loading: false
          })
        }
      },

      logout: () => {
        set({
          user: null,
          access_token: null,
          refresh_token: null,
          error: null
        })
      },

      setUser: (user) => set({ user })
    }),
    {
      name: 'auth-storage',  // localStorage key
      partialUpdate: true,   // Merge updates
    }
  )
)
Component Usage with Selectors Usage
// components/UserProfile.tsx
import { useAuthStore } from '@/stores/authStore'

function UserProfile() {
  // Subscribe to specific values only (prevents unnecessary re-renders)
  const user = useAuthStore(state => state.user)
  const logout = useAuthStore(state => state.logout)

  if (!user) return <LoginButton />

  return (
    <div>
      <span>{user.email}</span>
      <button onClick={logout}>Logout</button>
    </div>
  )
}

Route guards prevent unauthorized access to protected pages. The ProtectedRoute wrapper checks authentication state (from Tab 1’s Zustand store) and redirects to login if needed—one component protects your entire app.

Route Guards: Protect routes by checking authentication state before rendering. If user is null (from Tab 1's auth store), redirect to login. If authenticated, render the protected content. This wrapper pattern works with any component—wrap once, protect forever.

Loading States: Initial page load needs to check localStorage for existing tokens. Show loading spinner while verifying session. Prevents flash of login page for already-authenticated users. Uses the `loading` state from auth store.

Navigation Pattern: React Router v6's `<Navigate>` component handles redirects declaratively. The `replace` prop removes redirect from browser history—prevents back-button loops after login. Cleaner UX than imperative `navigate()` calls.

Return URL: Capture intended destination before redirecting to login. After successful authentication, navigate back to original route. Uses `location.state` to pass data between routes. Pattern: User clicks `/settings` → not authenticated → redirect to `/login` (save `/settings`) → login success → navigate to `/settings`.

ProtectedRoute Component Guard
// components/ProtectedRoute.tsx
import { Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'

interface ProtectedRouteProps {
  children: React.ReactNode
}

export function ProtectedRoute({ children }: ProtectedRouteProps) {
  const user = useAuthStore(state => state.user)
  const loading = useAuthStore(state => state.loading)
  const location = useLocation()

  // Still checking auth (initial load from localStorage)
  if (loading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="animate-spin rounded-full h-12 w-12 border-b-2" />
      </div>
    )
  }

  // Not authenticated - redirect with return URL
  if (!user) {
    return <Navigate to="/login" state={{ from: location.pathname }} replace />
  }

  // Authenticated - render children
  return <>{children}</>
}
App.tsx - Route Configuration Routes
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { ProtectedRoute } from './components/ProtectedRoute'

function App() {
  return (
    <BrowserRouter>
      <Routes>
        {/* Public Routes */}
        <Route path="/" element={<HomePage />} />
        <Route path="/login" element={<LoginPage />} />
        <Route path="/register" element={<RegisterPage />} />

        {/* Protected Routes */}
        <Route
          path="/chat"
          element={
            <ProtectedRoute>
              <ChatPage />
            </ProtectedRoute>
          }
        />
        <Route
          path="/settings"
          element={
            <ProtectedRoute>
              <SettingsPage />
            </ProtectedRoute>
          }
        />
      </Routes>
    </BrowserRouter>
  )
}
LoginPage - Return URL Handling Login
import { useLocation, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'

function LoginPage() {
  const location = useLocation()
  const navigate = useNavigate()
  const login = useAuthStore(state => state.login)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    await login(email, password)

    // Navigate to return URL or default to /chat
    const returnUrl = location.state?.from || '/chat'
    navigate(returnUrl, { replace: true })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" required />
      <input type="password" required />
      <button type="submit">Login</button>
    </form>
  )
}

Login and register forms submit credentials to the auth store actions from Tab 1. The store handles API calls, token storage, and error states—components stay simple with controlled inputs and submit handlers.

Form Submission: Login and register forms call the auth store actions created in Tab 1. Submit email + password, call `login()` or `register()`, store returns tokens and user data automatically. No direct API calls in components.

API Integration: Auth store handles the fetch calls to `/api/v1/auth/login` and `/api/v1/auth/register`. Store manages loading states, error handling, and token storage. Components only trigger actions and display results.

Navigation: After successful login/register, navigate to `/chat` or dashboard. Use the return URL pattern from Tab 2 if user was redirected from a protected route. Replace history entry to prevent back-button loops.

Error Handling: Auth store tracks `error` state. Display error messages from backend (invalid credentials, email already exists, network errors). Clear errors on new submission attempts—each login/register resets error state.

Login Form Login
// pages/LoginPage.tsx
import { useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'

function LoginPage() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const navigate = useNavigate()
  const location = useLocation()

  const login = useAuthStore(state => state.login)
  const loading = useAuthStore(state => state.loading)
  const error = useAuthStore(state => state.error)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    await login(email, password)

    // Navigate to return URL or default to /chat
    const returnUrl = location.state?.from || '/chat'
    navigate(returnUrl, { replace: true })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        required
      />
      {error && <p className="error">{error}</p>}
      <button type="submit" disabled={loading}>
        {loading ? 'Logging in...' : 'Login'}
      </button>
    </form>
  )
}
Register Form Register
// pages/RegisterPage.tsx
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'

function RegisterPage() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [name, setName] = useState('')
  const navigate = useNavigate()

  const register = useAuthStore(state => state.register)
  const loading = useAuthStore(state => state.loading)
  const error = useAuthStore(state => state.error)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    await register(email, password, name)

    // Navigate to chat after successful registration
    navigate('/chat', { replace: true })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Name"
        required
      />
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        required
      />
      {error && <p className="error">{error}</p>}
      <button type="submit" disabled={loading}>
        {loading ? 'Creating account...' : 'Register'}
      </button>
    </form>
  )
}

Access tokens expire after 15 minutes. Axios interceptors automatically catch 401 errors, refresh tokens, and retry requests. Users never notice the token refresh happening in the background.

Automatic Refresh: When access token expires (15 minutes), API returns 401 Unauthorized. Intercept 401 responses, call `/auth/refresh` with refresh token, update access token in store, retry original request—user never notices the interruption.

Interceptor Pattern: Axios response interceptor catches 401 errors globally across all API calls. No need to handle token refresh in every component. One interceptor protects the entire application.

Token Rotation: Backend issues new refresh token on each refresh (security best practice from Section 1, Tab 4). Update both `access_token` and `refresh_token` in auth store. Old refresh token is invalidated.

Refresh Failure: If refresh token expires (7 days) or is invalid, force logout. Clear auth store completely, redirect to `/login`. User must re-authenticate. Prevents infinite refresh loops when refresh token is compromised.

Axios Interceptor for Token Refresh Interceptor
// api/axiosInstance.ts
import axios from 'axios'
import { useAuthStore } from '@/stores/authStore'

const api = axios.create({
  baseURL: '/api/v1'
})

// Request interceptor: Add access token to every request
api.interceptors.request.use(
  (config) => {
    const token = useAuthStore.getState().access_token
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  }
)

// Response interceptor: Handle 401 and refresh token
api.interceptors.response.use(
  (response) => response,  // Success - pass through
  async (error) => {
    const originalRequest = error.config

    // 401 error and haven't retried yet
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true

      try {
        const { refresh_token } = useAuthStore.getState()

        // Call refresh endpoint
        const response = await axios.post('/api/v1/auth/refresh', {
          refresh_token
        })

        const { access_token, refresh_token: new_refresh } = response.data

        // Update tokens in store (rotation)
        useAuthStore.setState({
          access_token,
          refresh_token: new_refresh
        })

        // Retry original request with new token
        originalRequest.headers.Authorization = `Bearer ${access_token}`
        return api(originalRequest)
      } catch (refreshError) {
        // Refresh failed - logout and redirect
        useAuthStore.getState().logout()
        window.location.href = '/login'
        return Promise.reject(refreshError)
      }
    }

    return Promise.reject(error)
  }
)

export default api

// Usage in components
import api from '@/api/axiosInstance'

async function fetchChats() {
  const response = await api.get('/chats')  // Auto-adds token, auto-refreshes if expired
  return response.data
}

User-scoped data (chats, messages) is fetched from the backend on demand, not stored in localStorage. The backend is the source of truth. Use separate Zustand stores for different data domains (auth vs chats vs preferences).

Load on Mount: When user logs in or app loads with existing session, fetch user-scoped data (chat list, user profile) immediately. Use `useEffect` with user dependency—when user changes, fetch their data.

Storage Decision: Messages and chats fetched from backend on demand, stored in Zustand for current session only. NOT localStorage—data can be lost (browser clears it), security risk (sensitive conversations), sync issues (multiple devices), size limits (5-10MB). Backend is source of truth.

Chat Store Pattern: Separate Zustand store for chats (different concern from auth). Fetch chat list on mount, store in `chats` array. When user selects chat, fetch messages for that `session_id`. Messages keyed by session_id in object.

User ID Scoping: All data fetching filtered by user_id (backend enforces this for security). `/api/v1/chats` returns only current user's chats. `/api/v1/chat/[session_id]` verifies session ownership before returning messages.

Click to enlarge and zoom
Chat Store - Fetch User Data Store
// stores/chatStore.ts
import { create } from 'zustand'
import api from '@/api/axiosInstance'

interface Chat {
  session_id: string
  title: string
  last_activity: string
  message_count: number
}

interface Message {
  message_id: string
  role: 'user' | 'assistant'
  content: string
  created_at: string
}

interface ChatState {
  chats: Chat[]
  messages: Record<string, Message[]>  // session_id -> messages
  loading: boolean

  fetchChats: () => Promise<void>
  loadMessages: (sessionId: string) => Promise<void>
}

export const useChatStore = create<ChatState>((set, get) => ({
  chats: [],
  messages: {},
  loading: false,

  fetchChats: async () => {
    set({ loading: true })
    try {
      // Auto-includes Authorization header via interceptor
      const response = await api.get('/chats')
      set({ chats: response.data.chats, loading: false })
    } catch (error) {
      console.error('Failed to fetch chats:', error)
      set({ loading: false })
    }
  },

  loadMessages: async (sessionId) => {
    try {
      const response = await api.get(`/chat/${sessionId}/messages`)
      set(state => ({
        messages: {
          ...state.messages,
          [sessionId]: response.data.messages
        }
      }))
    } catch (error) {
      console.error('Failed to load messages:', error)
    }
  }
}))
Component - Load Data on Mount Usage
// components/ChatLayout.tsx
import { useEffect } from 'react'
import { useAuthStore } from '@/stores/authStore'
import { useChatStore } from '@/stores/chatStore'

function ChatLayout() {
  const user = useAuthStore(state => state.user)
  const fetchChats = useChatStore(state => state.fetchChats)
  const chats = useChatStore(state => state.chats)

  // Load chats when user logs in or component mounts
  useEffect(() => {
    if (user) {
      fetchChats()  // Fetch user's chat list
    }
  }, [user, fetchChats])

  return (
    <div>
      <h2>Your Chats</h2>
      {chats.map(chat => (
        <ChatItem key={chat.session_id} chat={chat} />
      ))}
    </div>
  )
}

// When user selects a chat
function ChatPage({ sessionId }: { sessionId: string }) {
  const loadMessages = useChatStore(state => state.loadMessages)
  const messages = useChatStore(state => state.messages[sessionId])

  // Load messages for this chat when sessionId changes
  useEffect(() => {
    loadMessages(sessionId)
  }, [sessionId, loadMessages])

  return (
    <div>
      {messages?.map(msg => (
        <Message key={msg.message_id} {...msg} />
      ))}
    </div>
  )
}

When users refresh the page at /chat/[sessionId] or navigate directly to a chat URL, the conversation history must load automatically. This requires a GET endpoint that fetches messages with ownership verification, and a useEffect hook in ChatPage that triggers on mount and sessionId changes.

The Problem: When you refresh the page at `/chat/[sessionId]` or navigate directly to a chat URL, the frontend starts with an empty messages array. Without a fetch mechanism, users see "Start a conversation" despite having message history stored in the database.

The Solution Pattern: Add a GET endpoint that returns all messages for a session, verify ownership (`session.user_id` matches current user), and fetch messages in ChatPage's `useEffect` when `sessionId` changes or component mounts. Messages load automatically on refresh.

Security Verification: Backend must verify `session.user_id == current_user.id` before returning messages. This prevents users from accessing other users' conversations by guessing `session_id` UUIDs. The `get_current_user` dependency (from Tab 2) enforces this.

Loading & Error States: Show loading indicator while fetching. Handle 404 for new sessions (no messages yet—this is normal). Handle 401 for expired tokens (automatic refresh via Tab 4's interceptor). Display errors gracefully without breaking the UI.

Backend GET Endpoint Backend
# app/api/chat.py
from fastapi import APIRouter, Depends, HTTPException
from app.core.dependencies import get_current_user
from app.models.user import User
from app.models.session import UserSession
from app.models.message import Message

@router.get("/chat/{session_id}/messages")
async def get_session_messages(
    session_id: str,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_async_session)
):
    """Get all messages for a session (with ownership verification)"""

    # Verify session exists and user owns it
    result = await db.execute(
        select(UserSession).where(
            UserSession.session_id == session_id,
            UserSession.user_id == str(current_user.id)
        )
    )
    session = result.scalar_one_or_none()

    if not session:
        raise HTTPException(404, "Session not found or access denied")

    # Fetch all messages for this session, ordered by sequence
    result = await db.execute(
        select(Message)
        .where(Message.session_id == session_id)
        .order_by(Message.sequence)
    )
    messages = result.scalars().all()

    return {
        "session_id": session_id,
        "messages": [msg.to_dict() for msg in messages]
    }
Frontend API Function API
// api/chat.ts
import api from './axiosInstance'

export interface Message {
  message_id: string
  session_id: string
  user_id: string
  sequence: number
  role: 'user' | 'assistant'
  content: string | ContentBlock[]
  tokens: number
  created_at: string
}

export async function getSessionMessages(
  sessionId: string
): Promise<Message[]> {
  try {
    const response = await api.get(`/chat/${sessionId}/messages`)
    return response.data.messages
  } catch (error) {
    // 404 is normal for new sessions (no messages yet)
    if (error.response?.status === 404) {
      return []
    }
    throw error
  }
}
ChatPage - Load History on Mount Component
// pages/ChatPage.tsx
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { getSessionMessages } from '@/api/chat'

function ChatPage() {
  const { sessionId } = useParams<{ sessionId: string }>()
  const [messages, setMessages] = useState<Message[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  // Load conversation history when sessionId changes or on mount
  useEffect(() => {
    if (!sessionId) return

    const loadHistory = async () => {
      setLoading(true)
      setError(null)

      try {
        const history = await getSessionMessages(sessionId)
        setMessages(history)
      } catch (err) {
        console.error('Failed to load messages:', err)
        setError('Failed to load conversation history')
      } finally {
        setLoading(false)
      }
    }

    loadHistory()
  }, [sessionId])

  if (loading) {
    return <div className="loading">Loading conversation...</div>
  }

  if (error) {
    return <div className="error">{error}</div>
  }

  return (
    <div>
      {messages.length === 0 ? (
        <EmptyState />
      ) : (
        messages.map(msg => (
          <Message key={msg.message_id} {...msg} />
        ))
      )}
      <ChatInput onSubmit={handleSendMessage} />
    </div>
  )
}

Conclusion

Authentication isn’t just login forms and password hashing—it’s the foundation that makes multi-user agent systems trustworthy and scalable. You’ve learned how JWT tokens enable stateless session management across distributed services, how token refresh patterns balance security with user experience, how Zustand coordinates authentication state across components, and how protected routes enforce access control declaratively. These patterns transform single-user prototypes into production-ready multi-tenant platforms.

This completes the Building Agent Interfaces series. Article I taught React’s mental model and design systems. Article II covered functional programming, state management, and API integration. Combined with authentication, you now have everything to create your own agent product—a complete full-stack system with production-ready security, multi-user support, and the patterns that power real-world AI applications. From component composition to token refresh, every layer is covered.

With this foundation in place, you can deploy to production with role-based access control, add OAuth providers for social login, or integrate enterprise SSO for organizational deployments. The architecture scales: local development with SQLite, staging with PostgreSQL + Redis, production with managed services and horizontal scaling. Start shipping—your agent system is production-ready.