coderain guide

Implementing Authentication and Authorization in React

Before diving into implementation, it’s crucial to distinguish between **authentication** and **authorization**: - **Authentication**: Verifies the identity of a user (e.g., "Who are you?"). It ensures users are who they claim to be via credentials like email/password, OAuth tokens, or biometrics. - **Authorization**: Determines what an authenticated user is allowed to do (e.g., "What can you access?"). It restricts actions based on roles (e.g., "admin" vs. "user") or permissions (e.g., "edit posts"). Together, they form the foundation of secure user interactions in React apps.

Authentication and authorization are critical components of modern web applications, ensuring that only verified users can access specific resources and actions. In React, implementing these features requires a combination of state management, secure token handling, route protection, and role-based access control. This guide will walk you through building a robust auth system in React, from basic login flows to advanced role-based authorization.

Table of Contents

  1. Introduction: Authentication vs. Authorization
  2. Prerequisites
  3. Authentication Methods Overview
  4. Project Setup
  5. Creating an Auth Context
  6. Implementing Login and Logout
  7. Protected Routes
  8. Role-Based Authorization
  9. Secure Token Handling
  10. Error Handling and Validation
  11. Best Practices
  12. References

Prerequisites

To follow along, you’ll need:

  • Basic knowledge of React (components, hooks, context API).
  • Familiarity with React Router (v6+) for navigation.
  • A backend API (or mock server) to handle authentication (we’ll use json-server for demonstration).
  • Understanding of HTTP requests (we’ll use axios).

Authentication Methods Overview

Common authentication methods for React apps include:

  1. JWT (JSON Web Tokens): Stateless tokens issued by the backend after login. Stored client-side and sent with each request.
  2. OAuth 2.0: Allows users to log in via third parties (Google, GitHub). Ideal for social logins.
  3. Session-Based Auth: Uses server-side sessions (e.g., cookies) to track authenticated users.

We’ll focus on JWT for this guide, as it’s widely used in SPAs (Single-Page Applications) and aligns with React’s client-side architecture.

Project Setup

Let’s set up a React project and install dependencies:

Step 1: Create a React App

npx create-react-app react-auth-demo  
cd react-auth-demo  

Step 2: Install Dependencies

# React Router for navigation  
npm install react-router-dom  

# Axios for HTTP requests  
npm install axios  

# Mock backend (json-server)  
npm install -D json-server  

Step 3: Set Up a Mock Backend

Create a db.json file in the project root for json-server:

{  
  "users": [  
    { "id": 1, "email": "[email protected]", "password": "password123", "role": "user" },  
    { "id": 2, "email": "[email protected]", "password": "admin123", "role": "admin" }  
  ],  
  "tokens": []  
}  

Add a script to package.json to start the mock server:

"scripts": {  
  "server": "json-server --watch db.json --port 5000"  
}  

Start the server:

npm run server  

Creating an Auth Context

To manage authentication state globally, we’ll use React’s Context API. This ensures user state (e.g., logged-in status, role) is accessible across components without prop drilling.

Step 1: Create AuthContext.js

// src/context/AuthContext.js  
import { createContext, useReducer, useContext, useEffect } from 'react';  

// Initial state  
const initialState = {  
  user: null,  
  token: null,  
  isAuthenticated: false,  
  loading: true,  
  error: null  
};  

// Create context  
const AuthContext = createContext(initialState);  

// Reducer to handle state updates  
const authReducer = (state, action) => {  
  switch (action.type) {  
    case 'LOGIN_SUCCESS':  
      return {  
        ...state,  
        isAuthenticated: true,  
        user: action.payload.user,  
        token: action.payload.token,  
        loading: false,  
        error: null  
      };  
    case 'LOGOUT':  
      return {  
        ...initialState,  
        loading: false  
      };  
    case 'AUTH_ERROR':  
      return {  
        ...initialState,  
        error: action.payload,  
        loading: false  
      };  
    default:  
      return state;  
  }  
};  

// Provider component  
export const AuthProvider = ({ children }) => {  
  const [state, dispatch] = useReducer(authReducer, initialState);  

  // Load user from token on app start (if token exists)  
  useEffect(() => {  
    const loadUser = async () => {  
      const token = localStorage.getItem('token'); // Replace with secure storage later  
      if (token) {  
        // Fetch user data from backend (mock example)  
        try {  
          const res = await axios.get('http://localhost:5000/users/1'); // Mock user  
          dispatch({  
            type: 'LOGIN_SUCCESS',  
            payload: { user: res.data, token }  
          });  
        } catch (err) {  
          localStorage.removeItem('token');  
          dispatch({ type: 'AUTH_ERROR', payload: 'Session expired' });  
        }  
      } else {  
        dispatch({ type: 'AUTH_ERROR', payload: null });  
      }  
    };  

    loadUser();  
  }, []);  

  // Login function  
  const login = async (email, password) => {  
    try {  
      // Mock login request (replace with real backend)  
      const res = await axios.get('http://localhost:5000/users', {  
        params: { email, password }  
      });  

      if (res.data.length === 0) {  
        throw new Error('Invalid credentials');  
      }  

      const user = res.data[0];  
      const token = 'mock-jwt-token'; // Backend would return a real token  

      // Store token (temporarily in localStorage; we’ll improve this later)  
      localStorage.setItem('token', token);  

      dispatch({  
        type: 'LOGIN_SUCCESS',  
        payload: { user, token }  
      });  
    } catch (err) {  
      dispatch({  
        type: 'AUTH_ERROR',  
        payload: err.response?.data?.message || err.message  
      });  
    }  
  };  

  // Logout function  
  const logout = () => {  
    localStorage.removeItem('token');  
    dispatch({ type: 'LOGOUT' });  
  };  

  return (  
    <AuthContext.Provider value={{ ...state, login, logout }}>  
      {children}  
    </AuthContext.Provider>  
  );  
};  

// Custom hook to use auth context  
export const useAuth = () => useContext(AuthContext);  

Step 2: Wrap App with AuthProvider

Update src/index.js to wrap your app with AuthProvider and BrowserRouter:

import { BrowserRouter as Router } from 'react-router-dom';  
import { AuthProvider } from './context/AuthContext';  

ReactDOM.createRoot(document.getElementById('root')).render(  
  <React.StrictMode>  
    <Router>  
      <AuthProvider>  
        <App />  
      </AuthProvider>  
    </Router>  
  </React.StrictMode>  
);  

Implementing Login and Logout

Login Form Component

Create a Login.js component to handle user input:

// src/components/Login.js  
import { useState } from 'react';  
import { useNavigate } from 'react-router-dom';  
import { useAuth } from '../context/AuthContext';  

const Login = () => {  
  const [email, setEmail] = useState('');  
  const [password, setPassword] = useState('');  
  const { login, error, loading } = useAuth();  
  const navigate = useNavigate();  

  const handleSubmit = async (e) => {  
    e.preventDefault();  
    await login(email, password);  
    navigate('/dashboard'); // Redirect after login  
  };  

  return (  
    <div className="login-container">  
      <h2>Login</h2>  
      {error && <div className="error">{error}</div>}  
      <form onSubmit={handleSubmit}>  
        <div>  
          <label>Email</label>  
          <input  
            type="email"  
            value={email}  
            onChange={(e) => setEmail(e.target.value)}  
            required  
          />  
        </div>  
        <div>  
          <label>Password</label>  
          <input  
            type="password"  
            value={password}  
            onChange={(e) => setPassword(e.target.value)}  
            required  
          />  
        </div>  
        <button disabled={loading}>Login</button>  
      </form>  
    </div>  
  );  
};  

export default Login;  

Logout Button

Add a logout button to a navbar or dashboard:

// src/components/Navbar.js  
import { Link } from 'react-router-dom';  
import { useAuth } from '../context/AuthContext';  

const Navbar = () => {  
  const { isAuthenticated, user, logout } = useAuth();  

  return (  
    <nav>  
      <Link to="/">Home</Link>  
      {isAuthenticated ? (  
        <>  
          <Link to="/dashboard">Dashboard</Link>  
          <span>Welcome, {user?.email}</span>  
          <button onClick={logout}>Logout</button>  
        </>  
      ) : (  
        <Link to="/login">Login</Link>  
      )}  
    </nav>  
  );  
};  

export default Navbar;  

Protected Routes

Protected routes restrict access to authenticated users. Create a PrivateRoute component to handle this:

// src/components/PrivateRoute.js  
import { Navigate, Outlet } from 'react-router-dom';  
import { useAuth } from '../context/AuthContext';  

const PrivateRoute = () => {  
  const { isAuthenticated, loading } = useAuth();  

  // Show loading state while checking auth  
  if (loading) return <div>Loading...</div>;  

  // Redirect to login if not authenticated  
  return isAuthenticated ? <Outlet /> : <Navigate to="/login" replace />;  
};  

export default PrivateRoute;  

Use PrivateRoute in App Routing

Update src/App.js to define routes:

import { Routes, Route } from 'react-router-dom';  
import Navbar from './components/Navbar';  
import Login from './components/Login';  
import Dashboard from './components/Dashboard';  
import PrivateRoute from './components/PrivateRoute';  
import Home from './components/Home';  

function App() {  
  return (  
    <div className="App">  
      <Navbar />  
      <Routes>  
        <Route path="/" element={<Home />} />  
        <Route path="/login" element={<Login />} />  
        {/* Protected routes */}  
        <Route element={<PrivateRoute />}>  
          <Route path="/dashboard" element={<Dashboard />} />  
        </Route>  
      </Routes>  
    </div>  
  );  
}  

export default App;  

Role-Based Authorization

To restrict access by user roles (e.g., “admin” vs. “user”), extend PrivateRoute to check roles:

Step 1: Create RoleBasedRoute

// src/components/RoleBasedRoute.js  
import { Navigate, Outlet } from 'react-router-dom';  
import { useAuth } from '../context/AuthContext';  

const RoleBasedRoute = ({ allowedRoles }) => {  
  const { isAuthenticated, user, loading } = useAuth();  

  if (loading) return <div>Loading...</div>;  

  // Check authentication and role  
  if (!isAuthenticated) return <Navigate to="/login" replace />;  
  if (!allowedRoles.includes(user.role)) return <Navigate to="/unauthorized" replace />;  

  return <Outlet />;  
};  

export default RoleBasedRoute;  

Step 2: Use Role-Based Routes

Update App.js to include admin-only routes:

// In App.js Routes  
<Route element={<RoleBasedRoute allowedRoles={['admin']} />}>  
  <Route path="/admin-panel" element={<AdminPanel />} />  
</Route>  

Step 3: Restrict UI Elements by Role

Hide/show buttons or links based on roles:

// In Dashboard.js  
const Dashboard = () => {  
  const { user } = useAuth();  

  return (  
    <div>  
      <h2>Dashboard</h2>  
      {user?.role === 'admin' && (  
        <button>Delete Users (Admin Only)</button>  
      )}  
    </div>  
  );  
};  

Secure Token Handling

Storing tokens in localStorage is vulnerable to XSS attacks. Use HttpOnly cookies instead, as they’re inaccessible to JavaScript and mitigate XSS risks.

Backend Setup (Simplified)

Your backend should return tokens in an HttpOnly cookie:

// Example response from backend  
Set-Cookie: access_token=jwt-token; HttpOnly; Secure; SameSite=Strict; Path=/  

Frontend Adjustments

Update the login/logout logic to work with cookies (no manual storage needed—browsers automatically send cookies with requests):

// In AuthContext.js login function  
const login = async (email, password) => {  
  try {  
    // Backend sets HttpOnly cookie on successful login  
    const res = await axios.post('http://localhost:5000/login', { email, password }, {  
      withCredentials: true // Required to send/receive cookies  
    });  

    dispatch({  
      type: 'LOGIN_SUCCESS',  
      payload: { user: res.data.user, token: res.data.token } // Token optional if using cookies  
    });  
  } catch (err) { /* ... */ }  
};  

// Logout function (backend clears the cookie)  
const logout = async () => {  
  await axios.post('http://localhost:5000/logout', {}, { withCredentials: true });  
  dispatch({ type: 'LOGOUT' });  
};  

Error Handling and Validation

Improve the login form with validation and error handling:

// Updated Login.js with validation  
import { useState } from 'react';  
import { useNavigate } from 'react-router-dom';  
import { useAuth } from '../context/AuthContext';  

const Login = () => {  
  const [email, setEmail] = useState('');  
  const [password, setPassword] = useState('');  
  const [formErrors, setFormErrors] = useState({});  
  const { login, error, loading } = useAuth();  
  const navigate = useNavigate();  

  // Validate form inputs  
  const validate = () => {  
    const errors = {};  
    if (!email) errors.email = 'Email is required';  
    if (!password) errors.password = 'Password is required';  
    if (password && password.length < 6) errors.password = 'Password must be 6+ characters';  
    return errors;  
  };  

  const handleSubmit = async (e) => {  
    e.preventDefault();  
    const errors = validate();  
    if (Object.keys(errors).length > 0) {  
      setFormErrors(errors);  
      return;  
    }  

    await login(email, password);  
    navigate('/dashboard');  
  };  

  return (  
    <div className="login-container">  
      <h2>Login</h2>  
      {error && <div className="error">{error}</div>}  
      <form onSubmit={handleSubmit}>  
        <div>  
          <label>Email</label>  
          <input  
            type="email"  
            value={email}  
            onChange={(e) => setEmail(e.target.value)}  
          />  
          {formErrors.email && <span className="error">{formErrors.email}</span>}  
        </div>  
        <div>  
          <label>Password</label>  
          <input  
            type="password"  
            value={password}  
            onChange={(e) => setPassword(e.target.value)}  
          />  
          {formErrors.password && <span className="error">{formErrors.password}</span>}  
        </div>  
        <button disabled={loading}>Login</button>  
      </form>  
    </div>  
  );  
};  

export default Login;  

Best Practices

  1. Use HTTPS: Always encrypt data in transit.
  2. Secure Token Storage: Prefer HttpOnly, Secure cookies over localStorage to prevent XSS.
  3. Short-Lived Tokens: Use short expiration times for access tokens and refresh tokens for re-authentication.
  4. Input Validation: Validate all user inputs on both client and server.
  5. Rate Limiting: Protect login endpoints from brute-force attacks with rate limiting.
  6. Avoid Sensitive Data in Frontend: Never store passwords or secrets in React code.

References

By following this guide, you’ve built a secure authentication and authorization system in React, complete with protected routes, role-based access, and secure token handling. Always prioritize security best practices and adapt the implementation to your backend and use case!