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.
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).
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.
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.
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.