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
- Introduction: Authentication vs. Authorization
- Prerequisites
- Authentication Methods Overview
- Project Setup
- Creating an Auth Context
- Implementing Login and Logout
- Protected Routes
- Role-Based Authorization
- Secure Token Handling
- Error Handling and Validation
- Best Practices
- 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-serverfor demonstration). - Understanding of HTTP requests (we’ll use
axios).
Authentication Methods Overview
Common authentication methods for React apps include:
- JWT (JSON Web Tokens): Stateless tokens issued by the backend after login. Stored client-side and sent with each request.
- OAuth 2.0: Allows users to log in via third parties (Google, GitHub). Ideal for social logins.
- 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
- Use HTTPS: Always encrypt data in transit.
- Secure Token Storage: Prefer HttpOnly, Secure cookies over
localStorageto prevent XSS. - Short-Lived Tokens: Use short expiration times for access tokens and refresh tokens for re-authentication.
- Input Validation: Validate all user inputs on both client and server.
- Rate Limiting: Protect login endpoints from brute-force attacks with rate limiting.
- Avoid Sensitive Data in Frontend: Never store passwords or secrets in React code.
References
- React Router Documentation
- JWT.io
- OWASP Authentication Cheatsheet
- Axios Documentation
- React Context API
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!