System Design: Design a Full Auth System
Day 29 of 40 โ React System Design Series

Hi, Iโm Richa โ a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons Iโve learned working on large codebases. My goal is to share practical insights โ not shortcuts โ for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If youโre serious about improving as an engineer, youโre in the right place.
Hey everyone, Richa here! ๐
Day 29.
We built auth concepts on Day 11, the auth service and API client on Day 12, protected routes on Day 13, the AuthContext on Day 14, and refresh tokens on Day 15.
Today we pull all of that together into one complete system design answer โ the kind you'd give in an interview when asked:
"Walk me through how you'd design authentication and authorisation for a React app."
This is the full picture โ from login flow to role-based access to what happens when tokens expire at 3am.
The scope โ what "full auth system" means
Authentication: Who are you? (Login, verify identity, issue tokens)
Authorisation: What can you do? (Roles, permissions, protect routes and UI elements)
A full system covers:
Login / logout / register
Token storage and refresh
Protected routes (unauthenticated โ redirect to login)
Role-based access (admin vs user vs editor)
Permission-based UI (show/hide buttons based on what user can do)
Persistent auth state across page refresh
Handling token expiry gracefully
Architecture overview
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ React App โ
โ โ
โ AuthProvider (context + state) โ
โ โโโ user: User | null โ
โ โโโ isLoading: boolean โ
โ โโโ isAuthenticated: boolean โ
โ โ
โ authService apiClient โ
โ โโโ login() โโโ request interceptor โ
โ โโโ logout() โโโ response interceptorโ
โ โโโ refresh() (401 โ auto refresh)โ
โ โโโ getMe() โ
โ โ
โ Route layer โ
โ โโโ ProtectedRoute (auth required) โ
โ โโโ RoleRoute (role required) โ
โ โโโ PublicOnlyRoute (redirect if authed) โ
โ โ
โ Permission layer โ
โ โโโ usePermission hook โ
โ โโโ <Can> component โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Token strategy โ why httpOnly cookies
This is the most important security decision in the whole system.
โ localStorage โ vulnerable to XSS
Any script on your page can read localStorage.
A single XSS vulnerability exposes all your users' tokens.
โ sessionStorage โ same XSS vulnerability as localStorage
โ
httpOnly cookies โ not accessible by JavaScript at all
Sent automatically by the browser on every request.
XSS cannot steal the token.
Requires CSRF protection (use SameSite=Strict or a CSRF token).
// authService.ts
export const authService = {
async login(credentials: LoginCredentials): Promise<User> {
// Server sets httpOnly cookie in the response
// We never handle the token in JS
const response = await apiClient.post<User>('/auth/login', credentials, {
withCredentials: true, // send/receive cookies cross-origin
});
return response.data;
},
async logout(): Promise<void> {
await apiClient.post('/auth/logout', null, { withCredentials: true });
// Server clears the httpOnly cookie
},
async getMe(): Promise<User> {
const response = await apiClient.get<User>('/auth/me', {
withCredentials: true,
});
return response.data;
},
async refresh(): Promise<void> {
await apiClient.post('/auth/refresh', null, { withCredentials: true });
// Server issues new access token cookie
},
};
The AuthProvider โ complete implementation
// context/AuthContext.tsx
import {
createContext, useContext, useEffect, useCallback,
useReducer, type ReactNode
} from 'react';
import { authService } from '@/services/authService';
type User = {
id: string;
email: string;
name: string;
role: 'admin' | 'editor' | 'viewer';
permissions: string[]; // ['posts:create', 'posts:delete', 'users:manage']
};
type AuthState = {
user: User | null;
isLoading: boolean;
error: string | null;
};
type AuthAction =
| { type: 'LOADING' }
| { type: 'SET_USER'; payload: User }
| { type: 'CLEAR_USER' }
| { type: 'SET_ERROR'; payload: string };
function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case 'LOADING': return { ...state, isLoading: true, error: null };
case 'SET_USER': return { user: action.payload, isLoading: false, error: null };
case 'CLEAR_USER': return { user: null, isLoading: false, error: null };
case 'SET_ERROR': return { ...state, isLoading: false, error: action.payload };
}
}
type AuthContextValue = {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
error: string | null;
login: (credentials: LoginCredentials) => Promise<void>;
logout: () => Promise<void>;
hasRole: (role: User['role']) => boolean;
hasPermission: (permission: string) => boolean;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(authReducer, {
user: null,
isLoading: true, // true on mount โ we don't know auth state yet
error: null,
});
// Check auth state on app load
useEffect(() => {
authService.getMe()
.then(user => dispatch({ type: 'SET_USER', payload: user }))
.catch(() => dispatch({ type: 'CLEAR_USER' }));
}, []);
const login = useCallback(async (credentials: LoginCredentials) => {
dispatch({ type: 'LOADING' });
try {
const user = await authService.login(credentials);
dispatch({ type: 'SET_USER', payload: user });
} catch (err) {
dispatch({ type: 'SET_ERROR', payload: 'Invalid credentials' });
throw err;
}
}, []);
const logout = useCallback(async () => {
await authService.logout();
dispatch({ type: 'CLEAR_USER' });
}, []);
const hasRole = useCallback(
(role: User['role']) => state.user?.role === role,
[state.user]
);
const hasPermission = useCallback(
(permission: string) => state.user?.permissions.includes(permission) ?? false,
[state.user]
);
return (
<AuthContext.Provider value={{
...state,
isAuthenticated: !!state.user,
login,
logout,
hasRole,
hasPermission,
}}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
return ctx;
}
Route protection โ three guard types
// components/guards/ProtectedRoute.tsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/context/AuthContext';
// 1. Auth guard โ must be logged in
export function ProtectedRoute({ children }: { children: ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) return <PageSpinner />; // wait for getMe() to resolve
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
}
// 2. Role guard โ must have a specific role
export function RoleRoute({
children,
role,
fallback = <Navigate to="/unauthorized" replace />,
}: {
children: ReactNode;
role: 'admin' | 'editor' | 'viewer';
fallback?: ReactNode;
}) {
const { hasRole, isLoading } = useAuth();
if (isLoading) return <PageSpinner />;
if (!hasRole(role)) return <>{fallback}</>;
return <>{children}</>;
}
// 3. Public-only guard โ redirect away if already logged in (login page)
export function PublicOnlyRoute({ children }: { children: ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) return <PageSpinner />;
if (isAuthenticated) {
const from = (location.state as any)?.from?.pathname ?? '/dashboard';
return <Navigate to={from} replace />;
}
return <>{children}</>;
}
// App.tsx โ route configuration
function App() {
return (
<AuthProvider>
<Routes>
<Route path="/login" element={
<PublicOnlyRoute>
<LoginPage />
</PublicOnlyRoute>
} />
<Route path="/dashboard" element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
} />
<Route path="/admin" element={
<ProtectedRoute>
<RoleRoute role="admin">
<AdminPage />
</RoleRoute>
</ProtectedRoute>
} />
</Routes>
</AuthProvider>
);
}
Permission-based UI โ the <Can> component
Role-based routes handle navigation. Permission-based UI handles individual features within a page.
// components/Can.tsx
import { useAuth } from '@/context/AuthContext';
type CanProps = {
permission: string;
children: ReactNode;
fallback?: ReactNode;
};
export function Can({ permission, children, fallback = null }: CanProps) {
const { hasPermission } = useAuth();
return hasPermission(permission) ? <>{children}</> : <>{fallback}</>;
}
// Usage in any component
function PostActions({ post }: { post: Post }) {
return (
<div className="post-actions">
<Can permission="posts:edit">
<EditButton postId={post.id} />
</Can>
<Can permission="posts:delete" fallback={<span className="no-permission">Delete (no access)</span>}>
<DeleteButton postId={post.id} />
</Can>
</div>
);
}
Automatic token refresh โ the 401 interceptor
When the access token expires, the API returns 401. The interceptor catches this and tries to refresh before the user sees an error.
// services/apiClient.ts
import axios from 'axios';
let isRefreshing = false;
let failedQueue: Array<{ resolve: () => void; reject: (err: unknown) => void }> = [];
const processQueue = (error: unknown) => {
failedQueue.forEach(prom => error ? prom.reject(error) : prom.resolve());
failedQueue = [];
};
apiClient.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// Queue requests while refresh is in progress
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: () => resolve(apiClient(originalRequest)),
reject,
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
await authService.refresh();
processQueue(null);
return apiClient(originalRequest); // retry original request
} catch (refreshError) {
processQueue(refreshError);
// Redirect to login โ user's session has fully expired
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
This is the same refresh queue we built on Day 15 โ it prevents the race condition where multiple 401s fire simultaneously and all try to refresh at once.
Interview talking points
| Topic | What to say |
|---|---|
| Token storage | "httpOnly cookies โ not accessible to JS, immune to XSS. Add SameSite=Strict for CSRF protection" |
| isLoading: true on mount | "We don't know auth state until getMe() resolves. Start loading=true, never show a flash of unauthenticated content" |
| Three-state auth model | "isLoading / isAuthenticated / not authenticated โ each needs different UI treatment" |
| Role vs permission | "Roles are coarse-grained (admin/editor). Permissions are fine-grained (posts:delete). Check permissions in UI, roles in route guards" |
| Refresh queue | "Multiple 401s race to refresh. Queue them โ only one refresh fires, all queued requests retry after" |
Today's takeaway
A full auth system has more moving parts than most people realise. The pieces that are easiest to get wrong:
Token storage โ always httpOnly cookies, never localStorage
Initial loading state โ always start
isLoading: trueon mountRefresh race condition โ queue concurrent 401s, don't fire multiple refreshes
Role vs permission โ coarse-grained vs fine-grained โ use both
When you can explain why each of these decisions exists, you're showing senior-level thinking.
See you tomorrow for Day 30 โ Design a shopping cart and checkout flow.
โ Day 28 โ Design a Real-Time Dashboardโ Day 30 โ Design a Shopping Cart
Part of the React System Design Series โ 40 days, one topic per day.






