Handling private routes incorrectly

Loading

Proper Implementation of Private Routes in React Router

A common security and UX issue in React applications is incorrectly implementing private/protected routes, potentially exposing restricted content to unauthorized users.

The Problem: Insecure Private Routes

// ❌ Problematic implementations:

// 1. No protection at all
<Route path="/dashboard" element={<Dashboard />} />

// 2. Conditional rendering without proper redirect
function App() {
  return (
    <Routes>
      {isLoggedIn && <Route path="/admin" element={<AdminPanel />} />}
    </Routes>
  );
}

// 3. Redirect without state preservation
function PrivateRoute() {
  if (!isAuthenticated) {
    return <Navigate to="/login" />;
  }
  return <Outlet />;
}

Why Proper Private Routes Matter

  1. Security: Prevents unauthorized access to protected content
  2. UX: Properly redirects unauthenticated users with context
  3. State Management: Maintains application state during auth flows
  4. Navigation History: Preserves browser history correctly

Correct Implementation Patterns

1. Basic Private Route Wrapper

import { Navigate, Outlet, useLocation } from 'react-router-dom';

function PrivateRoute() {
  const { isAuthenticated } = useAuth();
  const location = useLocation();

  return isAuthenticated ? (
    <Outlet />
  ) : (
    <Navigate to="/login" state={{ from: location }} replace />
  );
}

// Usage:
<Routes>
  <Route element={<PrivateRoute />}>
    <Route path="/dashboard" element={<Dashboard />} />
    <Route path="/profile" element={<Profile />} />
  </Route>
  <Route path="/login" element={<Login />} />
</Routes>

2. Role-Based Access Control

function RoleRoute({ allowedRoles }) {
  const { user } = useAuth();
  const location = useLocation();

  return allowedRoles.includes(user?.role) ? (
    <Outlet />
  ) : user ? (
    <Navigate to="/unauthorized" state={{ from: location }} replace />
  ) : (
    <Navigate to="/login" state={{ from: location }} replace />
  );
}

// Usage:
<Routes>
  <Route element={<RoleRoute allowedRoles={['admin', 'editor']} />}>
    <Route path="/admin" element={<AdminPanel />} />
  </Route>
</Routes>

3. With Route Loaders (React Router 6.4+)

const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      {
        path: 'dashboard',
        loader: async () => {
          const user = await getCurrentUser();
          if (!user) {
            throw redirect('/login');
          }
          return { user };
        },
        element: <Dashboard />
      }
    ]
  }
]);

Best Practices

  1. Always Redirect with State:
   <Navigate to="/login" state={{ from: location }} replace />
  1. Handle the Login Redirect:
   function Login() {
     const location = useLocation();
     const from = location.state?.from?.pathname || '/';

     const handleLogin = () => {
       login().then(() => {
         navigate(from, { replace: true });
       });
     };
   }
  1. Use Route Groups:
   <Route element={<PrivateRoute />}>
     {/* All child routes are protected */}
     <Route path="profile" element={<Profile />} />
     <Route path="settings" element={<Settings />} />
   </Route>
  1. Add Loading States:
   function PrivateRoute() {
     const { isLoading, isAuthenticated } = useAuth();
     const location = useLocation();

     if (isLoading) return <LoadingSpinner />;

     return isAuthenticated ? (
       <Outlet />
     ) : (
       <Navigate to="/login" state={{ from: location }} replace />
     );
   }
  1. Type Safety (TypeScript):
   interface LocationState {
     from: {
       pathname: string;
       search?: string;
       hash?: string;
     };
   }

   const location = useLocation();
   const state = location.state as LocationState;

Common Pitfalls

  1. Flash of Unauthorized Content:
  • Show loading state while checking auth
  • Don’t render protected content until auth is confirmed
  1. Infinite Redirect Loops:
   // ❌ Bad - infinite loop if login fails
   function PrivateRoute() {
     const { isAuthenticated } = useAuth();
     if (!isAuthenticated) return <Navigate to="/login" />;
     return <Outlet />;
   }
  1. Missing Route Protection:
  • Protect all routes that require authentication
  • Don’t rely solely on UI hiding (e.g., {user && <AdminPanel/>})
  1. Not Handling 401 Errors:
   useEffect(() => {
     fetch('/api/protected').catch(error => {
       if (error.response?.status === 401) {
         logout();
       }
     });
   }, []);

Advanced Patterns

1. Expired Session Handling

function PrivateRoute() {
  const { isAuthenticated, checkSession } = useAuth();
  const location = useLocation();
  const [isChecking, setIsChecking] = useState(true);

  useEffect(() => {
    checkSession().finally(() => setIsChecking(false));
  }, []);

  if (isChecking) return <LoadingSpinner />;

  return isAuthenticated ? (
    <Outlet />
  ) : (
    <Navigate to="/login" state={{ from: location }} replace />
  );
}

2. Route-Based Permissions

const permissionMap = {
  '/dashboard': ['user', 'admin'],
  '/admin': ['admin']
};

function PermissionRoute() {
  const { user } = useAuth();
  const location = useLocation();
  const allowedRoles = permissionMap[location.pathname];

  if (!allowedRoles) return <Outlet />;

  return allowedRoles.includes(user?.role) ? (
    <Outlet />
  ) : (
    <Navigate to="/unauthorized" replace />
  );
}

3. Password Reset Flow

function ResetPasswordRoute() {
  const { search } = useLocation();
  const token = new URLSearchParams(search).get('token');

  return token ? (
    <Outlet />
  ) : (
    <Navigate to="/forgot-password" replace />
  );
}

Remember: Proper private route implementation is crucial for both security and user experience. Always protect routes at the router level (not just component level), preserve navigation state, and handle edge cases like expired sessions.

Leave a Reply

Your email address will not be published. Required fields are marked *