Skip to main content

Command Palette

Search for a command to run...

System Design: Design a Full Auth System

Day 29 of 40 โ€” React System Design Series

Updated
โ€ข9 min read
System Design: Design a Full Auth System
R

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! ๐Ÿ‘‹

auth gif

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:

  1. Token storage โ€” always httpOnly cookies, never localStorage

  2. Initial loading state โ€” always start isLoading: true on mount

  3. Refresh race condition โ€” queue concurrent 401s, don't fire multiple refreshes

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

auth done gif

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.

React System Design โ€” Learning in Public

Part 29 of 34

I'm a React developer with 5.5 years of experience who spent 2 years in Lit.js โ€” and slowly forgot how React really works at a senior level. This series is my public journey back. Every day I cover one topic: state management, authentication, performance, system design interviews, testing โ€” the real stuff that separates a mid-level dev from a senior. Written honestly, with real code, for developers at every level.

Up next

System Design: Design a Shopping Cart and Checkout Flow

Day 30 of 40 โ€” React System Design Series