Table of Contents
- Prerequisites
- Setting Up the React Project
- Choosing an Authentication Method
- Setting Up a Backend (Optional but Recommended)
- Creating Authentication Components
- 5.1 Login Form
- 5.2 Register Form
- Managing Authentication State with Context API
- Storing Authentication Tokens
- Implementing Protected Routes
- Handling Token Expiration & Refresh Tokens
- Error Handling & Validation
- Testing the Authentication Flow
- Advanced Considerations
- Conclusion
- References
Prerequisites
Before diving in, ensure you have the following:
- Basic knowledge of React (components, hooks, state management).
- Node.js and npm/yarn installed (v14+ recommended).
- A code editor (VS Code preferred).
- Familiarity with REST APIs (for backend communication).
- Optional: A backend server (we’ll provide a simple Express example, or you can use services like Firebase Auth or Auth0).
Setting Up the React Project
First, create a new React project using create-react-app (or Vite, if you prefer):
npx create-react-app react-auth-demo
cd react-auth-demo
npm install react-router-dom axios
We’ll use:
react-router-dom: For routing and protected routes.axios: For making API requests to the backend.
Choosing an Authentication Method
For most React apps, JSON Web Tokens (JWT) are the go-to choice. JWTs are compact, self-contained tokens that securely transmit user data between the client and server. Here’s why they work well:
- Stateless: The server doesn’t store session data (scales better).
- Easy to integrate: Works with any backend (Node.js, Python, etc.).
Alternatives include:
- OAuth 2.0/OpenID Connect: For social logins (Google, Facebook).
- Firebase Auth/Auth0: Managed services (less boilerplate, more features).
We’ll focus on JWT for this guide, but the React-specific concepts (state management, protected routes) apply to all methods.
Setting Up a Backend (Optional but Recommended)
To test authentication, you’ll need a backend that:
- Registers users (stores hashed passwords).
- Logs users in (returns a JWT).
- Validates JWTs for protected endpoints.
Simple Express Backend Example
If you don’t have a backend, use this minimal Express server (save as backend/server.js):
mkdir backend && cd backend
npm init -y
npm install express cors body-parser jsonwebtoken bcryptjs
server.js:
const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const app = express();
app.use(cors());
app.use(bodyParser.json());
// Mock "database" (replace with PostgreSQL/MongoDB in production)
let users = [];
const JWT_SECRET = 'your_jwt_secret_key'; // Use environment variables in production!
// Register endpoint
app.post('/register', async (req, res) => {
const { email, password } = req.body;
// Check if user exists
if (users.find(u => u.email === email)) {
return res.status(400).json({ error: 'User already exists' });
}
// Hash password (never store plaintext!)
const hashedPassword = await bcrypt.hash(password, 10);
users.push({ email, password: hashedPassword });
res.status(201).json({ message: 'User registered' });
});
// Login endpoint (returns JWT)
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = users.find(u => u.email === email);
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate JWT (expires in 1h)
const token = jwt.sign({ email: user.email }, JWT_SECRET, { expiresIn: '1h' });
res.json({ token });
});
// Protected endpoint (requires valid JWT)
app.get('/protected', (req, res) => {
const token = req.headers.authorization?.split(' ')[1]; // Bearer <token>
if (!token) return res.status(401).json({ error: 'No token provided' });
try {
const decoded = jwt.verify(token, JWT_SECRET);
res.json({ message: 'Protected data', user: decoded.email });
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
});
app.listen(5000, () => console.log('Backend running on port 5000'));
Start the backend with node server.js.
Creating Authentication Components
We’ll build two core components: Login.js and Register.js.
5.1 Login Form
Create src/components/Login.js:
import { useState } from 'react';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
const Login = () => {
const [formData, setFormData] = useState({ email: '', password: '' });
const [error, setError] = useState('');
const navigate = useNavigate();
const handleChange = (e) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
const res = await axios.post('http://localhost:5000/login', formData);
// Store token (we’ll improve this later!)
localStorage.setItem('token', res.data.token);
navigate('/dashboard'); // Redirect to protected page
} catch (err) {
setError(err.response?.data?.error || 'Login failed');
}
};
return (
<div className="login">
<h2>Login</h2>
{error && <p className="error">{error}</p>}
<form onSubmit={handleSubmit}>
<div>
<label>Email:</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div>
<label>Password:</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
required
/>
</div>
<button type="submit">Login</button>
</form>
</div>
);
};
export default Login;
5.2 Register Form
Create src/components/Register.js (similar to Login, with minor tweaks):
import { useState } from 'react';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
const Register = () => {
const [formData, setFormData] = useState({ email: '', password: '' });
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const navigate = useNavigate();
const handleChange = (e) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setSuccess('');
try {
await axios.post('http://localhost:5000/register', formData);
setSuccess('Registration successful! Please log in.');
setTimeout(() => navigate('/login'), 2000);
} catch (err) {
setError(err.response?.data?.error || 'Registration failed');
}
};
return (
<div className="register">
<h2>Register</h2>
{error && <p className="error">{error}</p>}
{success && <p className="success">{success}</p>}
<form onSubmit={handleSubmit}>
<div>
<label>Email:</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div>
<label>Password:</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
required
minLength="6"
/>
</div>
<button type="submit">Register</button>
</form>
</div>
);
};
export default Register;
Managing Authentication State with Context API
To share authentication state (e.g., “is user logged in?”) across components, use React’s Context API. This avoids prop drilling and centralizes auth logic.
Step 1: Create Auth Context
Create src/context/AuthContext.js:
import { createContext, useContext, useState, useEffect } from 'react';
import axios from 'axios';
const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// Check if user is logged in on app load
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
fetchUser(token);
} else {
setLoading(false);
}
}, []);
// Fetch user data using the token
const fetchUser = async (token) => {
try {
const res = await axios.get('http://localhost:5000/protected', {
headers: { Authorization: `Bearer ${token}` },
});
setUser(res.data.user); // Set user from token payload
} catch (err) {
localStorage.removeItem('token'); // Token invalid/expired
} finally {
setLoading(false);
}
};
// Login function
const login = async (email, password) => {
const res = await axios.post('http://localhost:5000/login', { email, password });
localStorage.setItem('token', res.data.token);
await fetchUser(res.data.token);
};
// Logout function
const logout = () => {
localStorage.removeItem('token');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
};
// Custom hook to use auth context
export const useAuth = () => useContext(AuthContext);
Step 2: Wrap App with AuthProvider
Update src/App.js to use AuthProvider:
import { AuthProvider } from './context/AuthContext';
import { BrowserRouter as Router } from 'react-router-dom';
function App() {
return (
<AuthProvider>
<Router>
{/* Your routes will go here */}
</Router>
</AuthProvider>
);
}
export default App;
Update Login/Register to Use useAuth
Refactor Login.js to use the login function from useAuth (instead of direct axios calls):
// In Login.js
import { useAuth } from '../context/AuthContext';
const Login = () => {
// ... (previous code)
const { login } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
await login(formData.email, formData.password); // Use context login
navigate('/dashboard');
} catch (err) {
setError(err.response?.data?.error || 'Login failed');
}
};
// ...
};
Storing Authentication Tokens
Earlier, we used localStorage to store tokens, but this has security risks (vulnerable to XSS attacks). A better approach is HttpOnly cookies, which are inaccessible to JavaScript.
Why HttpOnly Cookies?
- Protected from XSS: Malicious scripts can’t read them.
- Automatically sent with every request (no manual header setup).
To use HttpOnly cookies:
- Backend: Set the JWT in a cookie (instead of returning it in the response body).
- Frontend: No need to store tokens manually—browsers handle cookies.
Example backend change (Express):
// In /login endpoint
res.cookie('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
maxAge: 3600000, // 1 hour
});
Implementing Protected Routes
Use react-router-dom to restrict access to routes like /dashboard to authenticated users.
Create a PrivateRoute Component
Create src/components/PrivateRoute.js:
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const PrivateRoute = () => {
const { user, loading } = useAuth();
if (loading) return <div>Loading...</div>; // Show spinner while checking auth
// Redirect to login if not logged in
return user ? <Outlet /> : <Navigate to="/login" />;
};
export default PrivateRoute;
Set Up Routes in App.js
Update src/App.js with routes:
import { Routes, Route } from 'react-router-dom';
import Login from './components/Login';
import Register from './components/Register';
import Dashboard from './components/Dashboard';
import PrivateRoute from './components/PrivateRoute';
import Navbar from './components/Navbar';
function App() {
return (
<AuthProvider>
<Router>
<Navbar />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
{/* Protected routes */}
<Route element={<PrivateRoute />}>
<Route path="/dashboard" element={<Dashboard />} />
</Route>
<Route path="/" element={<Navigate to="/login" />} />
</Routes>
</Router>
</AuthProvider>
);
}
Example Dashboard Component
Create src/components/Dashboard.js:
import { useAuth } from '../context/AuthContext';
const Dashboard = () => {
const { user, logout } = useAuth();
return (
<div className="dashboard">
<h2>Welcome, {user}!</h2>
<button onClick={logout}>Logout</button>
</div>
);
};
export default Dashboard;
Navbar Component
Create src/components/Navbar.js to show/hide links based on auth state:
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const Navbar = () => {
const { user, logout } = useAuth();
return (
<nav>
<Link to="/">Home</Link>
{user ? (
<>
<Link to="/dashboard">Dashboard</Link>
<button onClick={logout}>Logout</button>
</>
) : (
<>
<Link to="/login">Login</Link>
<Link to="/register">Register</Link>
</>
)}
</nav>
);
};
export default Navbar;
Handling Token Expiration & Refresh Tokens
JWTs should have short lifespans (e.g., 1 hour) for security. To avoid forcing users to log in repeatedly, use refresh tokens:
- On login, the backend returns a short-lived
accessTokenand a long-livedrefreshToken. - When the
accessTokenexpires, the frontend uses therefreshTokento get a newaccessToken.
Step 1: Backend Refresh Token Endpoint
Add this to your Express server:
// Add refresh token storage (in-memory for demo; use a database in production)
let refreshTokens = [];
// Login endpoint (return accessToken and refreshToken)
app.post('/login', async (req, res) => {
// ... (existing login logic)
const accessToken = jwt.sign({ email: user.email }, JWT_SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign({ email: user.email }, 'refresh_token_secret', { expiresIn: '7d' });
refreshTokens.push(refreshToken);
res.json({ accessToken, refreshToken });
});
// Refresh token endpoint
app.post('/refresh-token', (req, res) => {
const refreshToken = req.body.refreshToken;
if (!refreshToken || !refreshTokens.includes(refreshToken)) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
try {
const decoded = jwt.verify(refreshToken, 'refresh_token_secret');
const accessToken = jwt.sign({ email: decoded.email }, JWT_SECRET, { expiresIn: '15m' });
res.json({ accessToken });
} catch (err) {
res.status(401).json({ error: 'Refresh token expired' });
}
});
Step 2: Axios Interceptors for Token Refresh
Use axios interceptors to automatically refresh tokens when they expire. Create src/utils/axios.js:
import axios from 'axios';
import { useAuth } from '../context/AuthContext';
const api = axios.create({
baseURL: 'http://localhost:5000',
});
// Request interceptor: Add auth header
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor: Handle token expiration
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If error is 401 and we haven’t retried yet
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
const res = await axios.post('http://localhost:5000/refresh-token', { refreshToken });
localStorage.setItem('accessToken', res.data.accessToken);
// Retry the original request with new token
originalRequest.headers.Authorization = `Bearer ${res.data.accessToken}`;
return api(originalRequest);
} catch (err) {
// Refresh token expired: logout user
const { logout } = useAuth();
logout();
return Promise.reject(err);
}
}
return Promise.reject(error);
}
);
export default api;
Error Handling & Validation
- Form Validation: Use libraries like
react-hook-formor HTML5 validation (required,minLength). - API Errors: Display user-friendly messages (e.g., “Invalid email” instead of “401 Unauthorized”).
- Network Errors: Handle cases where the backend is down (e.g., “Could not connect to server”).
Testing the Authentication Flow
- Register: Create a user (e.g.,
[email protected]with passwordpassword123). - Login: Use the credentials to log in—you should redirect to
/dashboard. - Protected Route: Try accessing
/dashboarddirectly without logging in—you’ll redirect to/login. - Logout: Click “Logout” to clear the token and return to the login page.
Advanced Considerations
- Security Best Practices:
- Use HTTPS everywhere (never send tokens over HTTP).
- Hash passwords with
bcrypt(never store plaintext). - Limit token lifetimes (short-lived access tokens).
- Implement CSRF protection for cookies.
- Alternative Auth Services:
- Auth0: Handles social logins, MFA, and more.
- Firebase Auth: Easy setup with built-in UI components.
- State Management: For larger apps, use Redux or Zustand instead of Context API for better performance.
Conclusion
You now have a fully functional authentication system in React using JWT! Key takeaways:
- Use Context API to manage global auth state.
- Protect routes with
react-router-dom. - Secure tokens with HttpOnly cookies (avoid
localStoragefor production). - Handle token expiration with refresh tokens.
Always prioritize security—follow best practices like HTTPS, password hashing, and short-lived tokens.