Introduction to Authentication in Web Applications
Authentication is the process of verifying the identity of a user, system, or entity. In web applications, authentication is typically implemented through a combination of username/password credentials, tokens, or third-party authentication providers.
A robust authentication system is critical for:
- Protecting user data and privacy
- Controlling access to restricted features and content
- Providing personalized user experiences
- Preventing unauthorized access and potential security breaches
- Maintaining compliance with data protection regulations
In a MERN (MongoDB, Express, React, Node.js) stack application, authentication typically follows a token-based approach, where the server issues a JSON Web Token (JWT) upon successful login. This token is then included in subsequent requests to authenticate the user.
Authentication Strategies for MERN Applications
Session-based Authentication vs. Token-based Authentication
| Session-based Authentication | Token-based Authentication |
|---|---|
| Session ID stored in cookie | Token stored in localStorage, sessionStorage, or cookie |
| Session state stored on server | Stateless - server doesn't store session data |
| Works well for server-rendered applications | Ideal for single-page applications and APIs |
| Can be challenging for distributed systems | Scales easily across multiple servers |
| Vulnerable to CSRF attacks | Vulnerable to XSS attacks if stored in localStorage |
Real-world analogy: Session-based authentication is like checking into a hotel where they give you a room key card and keep your information at the front desk. Every time you enter, they check your key and their records. Token-based authentication is like an all-access pass to a convention - the pass itself contains all the information needed to verify your identity and access rights.
JWT (JSON Web Tokens)
JSON Web Tokens have become the standard for token-based authentication in modern web applications. A JWT consists of three parts:
- Header: Contains the token type and signing algorithm
- Payload: Contains claims or statements about the user and token
- Signature: Ensures the token hasn't been tampered with
// JWT structure
header.payload.signature
// Example JWT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Key benefits of JWTs:
- Stateless - no need to store session data on the server
- Scalable - works across multiple servers and microservices
- Secure - signed to prevent tampering
- Compact - can be sent in HTTP headers
- Self-contained - includes all necessary user information
- Expiration - can be configured to expire after a certain time
OAuth and Social Authentication
OAuth is an open standard for access delegation, commonly used for social authentication (login with Google, Facebook, GitHub, etc.). It allows users to grant websites or applications access to their information on other services without giving them their passwords.
When to use OAuth:
- When you want to provide a frictionless sign-up/login experience
- When you need access to user data from third-party platforms
- When you want to reduce the burden of password management
- When you need to build trust through association with established platforms
Implementing Authentication in Express.js Backend
User Model in MongoDB
First, let's create a user schema in MongoDB using Mongoose:
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'Please provide a name'],
trim: true,
maxlength: [50, 'Name cannot be more than 50 characters']
},
email: {
type: String,
required: [true, 'Please provide an email'],
unique: true,
lowercase: true,
match: [
/^([\w-\.]+@([\w-]+\.)+[\w-]{2,4})?$/,
'Please provide a valid email'
]
},
password: {
type: String,
required: [true, 'Please provide a password'],
minlength: [6, 'Password must be at least 6 characters'],
select: false // Don't return password in queries by default
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user'
},
resetPasswordToken: String,
resetPasswordExpire: Date
}, {
timestamps: true
});
// Encrypt password using bcrypt
userSchema.pre('save', async function(next) {
// Only run this function if password was modified
if (!this.isModified('password')) return next();
// Generate salt
const salt = await bcrypt.genSalt(10);
// Hash password with salt
this.password = await bcrypt.hash(this.password, salt);
next();
});
// Method to compare password
userSchema.methods.comparePassword = async function(candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
Authentication Controller
Now, let's implement the authentication controller for handling registration, login, and logout:
// controllers/authController.js
const User = require('../models/User');
const jwt = require('jsonwebtoken');
// Generate JWT token
const generateToken = (id) => {
return jwt.sign({ id }, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRE
});
};
// Register a new user
exports.register = async (req, res, next) => {
try {
const { name, email, password } = req.body;
// Check if user already exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({
success: false,
message: 'Email is already registered'
});
}
// Create user
const user = await User.create({
name,
email,
password
});
// Generate token
const token = generateToken(user._id);
res.status(201).json({
success: true,
token,
user: {
id: user._id,
name: user.name,
email: user.email,
role: user.role
}
});
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({
success: false,
message: 'Server error during registration'
});
}
};
// Login user
exports.login = async (req, res, next) => {
try {
const { email, password } = req.body;
// Validate email & password
if (!email || !password) {
return res.status(400).json({
success: false,
message: 'Please provide an email and password'
});
}
// Check for user
// We need to explicitly select the password as it's excluded by default
const user = await User.findOne({ email }).select('+password');
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid credentials'
});
}
// Check if password matches
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return res.status(401).json({
success: false,
message: 'Invalid credentials'
});
}
// Generate token
const token = generateToken(user._id);
res.status(200).json({
success: true,
token,
user: {
id: user._id,
name: user.name,
email: user.email,
role: user.role
}
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({
success: false,
message: 'Server error during login'
});
}
};
// Get current user
exports.getMe = async (req, res, next) => {
try {
// user is already available in req due to the auth middleware
const user = await User.findById(req.user.id);
res.status(200).json({
success: true,
data: user
});
} catch (error) {
console.error('Get current user error:', error);
res.status(500).json({
success: false,
message: 'Server error'
});
}
};
// Logout user (client-side only in JWT auth)
exports.logout = (req, res, next) => {
res.status(200).json({
success: true,
message: 'Logged out successfully'
});
};
Authentication Middleware
Let's create middleware to protect routes that require authentication:
// middleware/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
// Protect routes
exports.protect = async (req, res, next) => {
try {
let token;
// Get token from Authorization header
if (
req.headers.authorization &&
req.headers.authorization.startsWith('Bearer')
) {
// Format: Bearer {token}
token = req.headers.authorization.split(' ')[1];
}
// Check if token exists
if (!token) {
return res.status(401).json({
success: false,
message: 'Access denied. No token provided.'
});
}
try {
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Add user to request object
req.user = await User.findById(decoded.id);
next();
} catch (error) {
return res.status(401).json({
success: false,
message: 'Invalid token'
});
}
} catch (error) {
console.error('Auth middleware error:', error);
res.status(500).json({
success: false,
message: 'Server error'
});
}
};
// Grant access to specific roles
exports.authorize = (...roles) => {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({
success: false,
message: `User role ${req.user.role} is not authorized to access this resource`
});
}
next();
};
};
Authentication Routes
Now, let's create the authentication routes:
// routes/auth.js
const express = require('express');
const router = express.Router();
const {
register,
login,
getMe,
logout
} = require('../controllers/authController');
const { protect } = require('../middleware/auth');
router.post('/register', register);
router.post('/login', login);
router.get('/me', protect, getMe);
router.get('/logout', logout);
module.exports = router;
Finally, let's add these routes to our main Express app:
// server.js
const express = require('express');
const cors = require('cors');
const connectDB = require('./config/db');
require('dotenv').config();
// Route files
const auth = require('./routes/auth');
// Initialize app
const app = express();
// Connect to database
connectDB();
// Middleware
app.use(cors());
app.use(express.json());
// Mount routers
app.use('/api/auth', auth);
// Error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
success: false,
message: 'Server Error'
});
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Implementing Authentication in React Frontend
Authentication Context
In React, we can use Context API to manage authentication state across the application:
// src/context/auth/AuthContext.js
import React, { createContext, useReducer, useEffect } from 'react';
import axios from 'axios';
import authReducer from './authReducer';
// Create context
export const AuthContext = createContext();
// Initial state
const initialState = {
token: localStorage.getItem('token'),
isAuthenticated: null,
loading: true,
user: null,
error: null
};
// Provider component
export const AuthProvider = ({ children }) => {
const [state, dispatch] = useReducer(authReducer, initialState);
// Load user if token exists
useEffect(() => {
if (localStorage.token) {
loadUser();
} else {
dispatch({ type: 'AUTH_ERROR' });
}
}, []);
// Load user
const loadUser = async () => {
try {
setAuthToken(localStorage.token);
const res = await axios.get('/api/auth/me');
dispatch({
type: 'USER_LOADED',
payload: res.data.data
});
} catch (err) {
dispatch({ type: 'AUTH_ERROR' });
}
};
// Register user
const register = async (formData) => {
const config = {
headers: {
'Content-Type': 'application/json'
}
};
try {
const res = await axios.post('/api/auth/register', formData, config);
dispatch({
type: 'REGISTER_SUCCESS',
payload: res.data
});
loadUser();
} catch (err) {
dispatch({
type: 'REGISTER_FAIL',
payload: err.response?.data?.message || 'Registration failed'
});
}
};
// Login user
const login = async (formData) => {
const config = {
headers: {
'Content-Type': 'application/json'
}
};
try {
const res = await axios.post('/api/auth/login', formData, config);
dispatch({
type: 'LOGIN_SUCCESS',
payload: res.data
});
loadUser();
} catch (err) {
dispatch({
type: 'LOGIN_FAIL',
payload: err.response?.data?.message || 'Login failed'
});
}
};
// Logout
const logout = () => {
dispatch({ type: 'LOGOUT' });
};
// Clear errors
const clearErrors = () => {
dispatch({ type: 'CLEAR_ERRORS' });
};
return (
<AuthContext.Provider
value={{
token: state.token,
isAuthenticated: state.isAuthenticated,
loading: state.loading,
user: state.user,
error: state.error,
register,
login,
logout,
loadUser,
clearErrors
}}
>
{children}
</AuthContext.Provider>
);
};
// Set auth token in headers
export const setAuthToken = (token) => {
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
} else {
delete axios.defaults.headers.common['Authorization'];
}
};
Authentication Reducer
Let's create a reducer to handle authentication state changes:
// src/context/auth/authReducer.js
const authReducer = (state, action) => {
switch (action.type) {
case 'USER_LOADED':
return {
...state,
isAuthenticated: true,
loading: false,
user: action.payload
};
case 'REGISTER_SUCCESS':
case 'LOGIN_SUCCESS':
localStorage.setItem('token', action.payload.token);
return {
...state,
...action.payload,
isAuthenticated: true,
loading: false,
error: null
};
case 'REGISTER_FAIL':
case 'LOGIN_FAIL':
case 'AUTH_ERROR':
case 'LOGOUT':
localStorage.removeItem('token');
return {
...state,
token: null,
isAuthenticated: false,
loading: false,
user: null,
error: action.payload
};
case 'CLEAR_ERRORS':
return {
...state,
error: null
};
default:
return state;
}
};
export default authReducer;
Protected Route Component
We need a component to protect routes that require authentication:
// src/components/routing/PrivateRoute.js
import React, { useContext } from 'react';
import { Navigate } from 'react-router-dom';
import { AuthContext } from '../../context/auth/AuthContext';
const PrivateRoute = ({ children }) => {
const { isAuthenticated, loading } = useContext(AuthContext);
if (loading) {
return <div>Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" />;
}
return children;
};
export default PrivateRoute;
Register Component
// src/components/auth/Register.js
import React, { useState, useContext, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { AuthContext } from '../../context/auth/AuthContext';
const Register = () => {
const navigate = useNavigate();
const { register, error, isAuthenticated, clearErrors } = useContext(AuthContext);
useEffect(() => {
// Redirect if already authenticated
if (isAuthenticated) {
navigate('/dashboard');
}
}, [isAuthenticated, navigate]);
const [user, setUser] = useState({
name: '',
email: '',
password: '',
confirmPassword: ''
});
const [formErrors, setFormErrors] = useState({});
const { name, email, password, confirmPassword } = user;
const onChange = e => {
setUser({ ...user, [e.target.name]: e.target.value });
// Clear form error when field is updated
if (formErrors[e.target.name]) {
setFormErrors({
...formErrors,
[e.target.name]: null
});
}
};
const validateForm = () => {
const errors = {};
if (!name.trim()) {
errors.name = 'Name is required';
}
if (!email) {
errors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(email)) {
errors.email = 'Email is invalid';
}
if (!password) {
errors.password = 'Password is required';
} else if (password.length < 6) {
errors.password = 'Password must be at least 6 characters';
}
if (password !== confirmPassword) {
errors.confirmPassword = 'Passwords do not match';
}
setFormErrors(errors);
// Return true if no errors
return Object.keys(errors).length === 0;
};
const onSubmit = e => {
e.preventDefault();
if (validateForm()) {
register({
name,
email,
password
});
}
};
useEffect(() => {
// Clear auth errors when component unmounts
return () => {
clearErrors();
};
}, [clearErrors]);
return (
<div className="form-container">
<h1>Account <span className="text-primary">Register</span></h1>
{error && <div className="alert alert-danger">{error}</div>}
<form onSubmit={onSubmit}>
<div className="form-group">
<label htmlFor="name">Name</label>
<input
type="text"
name="name"
value={name}
onChange={onChange}
className={formErrors.name ? 'error' : ''}
/>
{formErrors.name && <p className="error-text">{formErrors.name}</p>}
</div>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
name="email"
value={email}
onChange={onChange}
className={formErrors.email ? 'error' : ''}
/>
{formErrors.email && <p className="error-text">{formErrors.email}</p>}
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
name="password"
value={password}
onChange={onChange}
className={formErrors.password ? 'error' : ''}
/>
{formErrors.password && <p className="error-text">{formErrors.password}</p>}
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password</label>
<input
type="password"
name="confirmPassword"
value={confirmPassword}
onChange={onChange}
className={formErrors.confirmPassword ? 'error' : ''}
/>
{formErrors.confirmPassword && (
<p className="error-text">{formErrors.confirmPassword}</p>
)}
</div>
<input
type="submit"
value="Register"
className="btn btn-primary btn-block"
/>
</form>
<p className="my-1">
Already have an account? <Link to="/login">Login</Link>
</p>
</div>
);
};
export default Register;
Login Component
// src/components/auth/Login.js
import React, { useState, useContext, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { AuthContext } from '../../context/auth/AuthContext';
const Login = () => {
const navigate = useNavigate();
const { login, error, isAuthenticated, clearErrors } = useContext(AuthContext);
useEffect(() => {
// Redirect if already authenticated
if (isAuthenticated) {
navigate('/dashboard');
}
}, [isAuthenticated, navigate]);
const [user, setUser] = useState({
email: '',
password: ''
});
const [formErrors, setFormErrors] = useState({});
const { email, password } = user;
const onChange = e => {
setUser({ ...user, [e.target.name]: e.target.value });
// Clear form error when field is updated
if (formErrors[e.target.name]) {
setFormErrors({
...formErrors,
[e.target.name]: null
});
}
};
const validateForm = () => {
const errors = {};
if (!email) {
errors.email = 'Email is required';
}
if (!password) {
errors.password = 'Password is required';
}
setFormErrors(errors);
// Return true if no errors
return Object.keys(errors).length === 0;
};
const onSubmit = e => {
e.preventDefault();
if (validateForm()) {
login({
email,
password
});
}
};
useEffect(() => {
// Clear auth errors when component unmounts
return () => {
clearErrors();
};
}, [clearErrors]);
return (
<div className="form-container">
<h1>Account <span className="text-primary">Login</span></h1>
{error && <div className="alert alert-danger">{error}</div>}
<form onSubmit={onSubmit}>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
name="email"
value={email}
onChange={onChange}
className={formErrors.email ? 'error' : ''}
/>
{formErrors.email && <p className="error-text">{formErrors.email}</p>}
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
name="password"
value={password}
onChange={onChange}
className={formErrors.password ? 'error' : ''}
/>
{formErrors.password && <p className="error-text">{formErrors.password}</p>}
</div>
<input
type="submit"
value="Login"
className="btn btn-primary btn-block"
/>
</form>
<p className="my-1">
Don't have an account? <Link to="/register">Register</Link>
</p>
</div>
);
};
export default Login;
Setting Up Routes
Let's set up the routes in our App component:
// src/App.js
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Navbar from './components/layout/Navbar';
import Home from './components/pages/Home';
import About from './components/pages/About';
import Register from './components/auth/Register';
import Login from './components/auth/Login';
import Dashboard from './components/pages/Dashboard';
import PrivateRoute from './components/routing/PrivateRoute';
import { AuthProvider } from './context/auth/AuthContext';
import './App.css';
const App = () => {
return (
<AuthProvider>
<Router>
<Navbar />
<div className="container">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/register" element={<Register />} />
<Route path="/login" element={<Login />} />
<Route
path="/dashboard"
element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}
/>
</Routes>
</div>
</Router>
</AuthProvider>
);
};
export default App;
Dashboard Component
// src/components/pages/Dashboard.js
import React, { useContext, useEffect } from 'react';
import { AuthContext } from '../../context/auth/AuthContext';
const Dashboard = () => {
const { user, loadUser } = useContext(AuthContext);
useEffect(() => {
// Refresh user data when component mounts
loadUser();
}, [loadUser]);
if (!user) {
return <div>Loading...</div>;
}
return (
<div>
<h1>Dashboard</h1>
<div className="user-info">
<h2>Welcome, {user.name}</h2>
<p><strong>Email:</strong> {user.email}</p>
<p><strong>Role:</strong> {user.role}</p>
<p><strong>Joined:</strong> {new Date(user.createdAt).toLocaleDateString()}</p>
</div>
</div>
);
};
export default Dashboard;
Navbar Component with Authentication
// src/components/layout/Navbar.js
import React, { useContext } from 'react';
import { Link } from 'react-router-dom';
import { AuthContext } from '../../context/auth/AuthContext';
const Navbar = () => {
const { isAuthenticated, logout, user } = useContext(AuthContext);
const onLogout = () => {
logout();
};
const authLinks = (
<ul>
{user && <li>Hello, {user.name}</li>}
<li>
<Link to="/dashboard">Dashboard</Link>
</li>
<li>
<a onClick={onLogout} href="#!">
<i className="fas fa-sign-out-alt"></i>
<span className="hide-sm">Logout</span>
</a>
</li>
</ul>
);
const guestLinks = (
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/about">About</Link>
</li>
<li>
<Link to="/register">Register</Link>
</li>
<li>
<Link to="/login">Login</Link>
</li>
</ul>
);
return (
<div className="navbar bg-primary">
<h1>
<i className="fas fa-code"></i> MERN App
</h1>
<div>{isAuthenticated ? authLinks : guestLinks}</div>
</div>
);
};
export default Navbar;
Handling Token Refresh and Expiration
JWT tokens should be short-lived for security reasons, but this means they will expire while users are active. There are several strategies to handle token expiration:
Silent Token Refresh
One approach is to use a refresh token to get a new access token when the original expires:
// controllers/authController.js - Add refresh token functionality
const createTokens = (userId) => {
// Create access token
const accessToken = jwt.sign(
{ id: userId },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // Short-lived
);
// Create refresh token
const refreshToken = jwt.sign(
{ id: userId },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' } // Longer-lived
);
return { accessToken, refreshToken };
};
// Login user - updated
exports.login = async (req, res, next) => {
try {
const { email, password } = req.body;
// Validate email & password
if (!email || !password) {
return res.status(400).json({
success: false,
message: 'Please provide an email and password'
});
}
// Check for user
const user = await User.findOne({ email }).select('+password');
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid credentials'
});
}
// Check if password matches
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return res.status(401).json({
success: false,
message: 'Invalid credentials'
});
}
// Generate tokens
const { accessToken, refreshToken } = createTokens(user._id);
res.status(200).json({
success: true,
accessToken,
refreshToken,
user: {
id: user._id,
name: user.name,
email: user.email,
role: user.role
}
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({
success: false,
message: 'Server error during login'
});
}
};
// New route for token refresh
exports.refreshToken = async (req, res, next) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({
success: false,
message: 'No refresh token provided'
});
}
try {
// Verify refresh token
const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
// Generate new tokens
const { accessToken, refreshToken: newRefreshToken } = createTokens(decoded.id);
return res.status(200).json({
success: true,
accessToken,
refreshToken: newRefreshToken
});
} catch (err) {
return res.status(401).json({
success: false,
message: 'Invalid refresh token'
});
}
} catch (error) {
console.error('Refresh token error:', error);
res.status(500).json({
success: false,
message: 'Server error'
});
}
};
Update the auth routes to include the refresh endpoint:
// routes/auth.js
router.post('/refresh-token', refreshToken);
On the frontend, we need to modify our authentication logic to handle token refresh:
// src/context/auth/AuthContext.js - Add token refresh functionality
import React, { createContext, useReducer, useEffect } from 'react';
import axios from 'axios';
import authReducer from './authReducer';
import jwtDecode from 'jwt-decode'; // Add this package
// Create context
export const AuthContext = createContext();
// Initial state
const initialState = {
accessToken: localStorage.getItem('accessToken'),
refreshToken: localStorage.getItem('refreshToken'),
isAuthenticated: null,
loading: true,
user: null,
error: null
};
// Provider component
export const AuthProvider = ({ children }) => {
const [state, dispatch] = useReducer(authReducer, initialState);
// Load user if token exists
useEffect(() => {
if (localStorage.accessToken) {
loadUser();
} else {
dispatch({ type: 'AUTH_ERROR' });
}
}, []);
// Set up token refresh interval
useEffect(() => {
// Check if we have a refresh token
if (!state.refreshToken) return;
// Function to check token expiration and refresh if needed
const checkTokenExpiration = async () => {
try {
// Check if access token is expired or will expire soon
if (state.accessToken) {
const decodedToken = jwtDecode(state.accessToken);
const currentTime = Date.now() / 1000;
// If token is expired or will expire in the next minute
if (decodedToken.exp < currentTime + 60) {
await refreshToken();
}
} else if (state.refreshToken) {
// If we don't have an access token but have a refresh token
await refreshToken();
}
} catch (err) {
console.error('Token refresh error:', err);
}
};
// Check token expiration immediately
checkTokenExpiration();
// Set up interval to check token expiration
const interval = setInterval(checkTokenExpiration, 60000); // Check every minute
// Clean up interval
return () => clearInterval(interval);
}, [state.accessToken, state.refreshToken]);
// Load user
const loadUser = async () => {
try {
setAuthToken(state.accessToken);
const res = await axios.get('/api/auth/me');
dispatch({
type: 'USER_LOADED',
payload: res.data.data
});
} catch (err) {
dispatch({ type: 'AUTH_ERROR' });
}
};
// Refresh token
const refreshToken = async () => {
try {
const res = await axios.post('/api/auth/refresh-token', {
refreshToken: state.refreshToken
});
dispatch({
type: 'TOKEN_REFRESHED',
payload: {
accessToken: res.data.accessToken,
refreshToken: res.data.refreshToken
}
});
return res.data;
} catch (err) {
dispatch({ type: 'AUTH_ERROR' });
throw err;
}
};
// Register user
const register = async (formData) => {
const config = {
headers: {
'Content-Type': 'application/json'
}
};
try {
const res = await axios.post('/api/auth/register', formData, config);
dispatch({
type: 'REGISTER_SUCCESS',
payload: res.data
});
loadUser();
} catch (err) {
dispatch({
type: 'REGISTER_FAIL',
payload: err.response?.data?.message || 'Registration failed'
});
}
};
// Login user
const login = async (formData) => {
const config = {
headers: {
'Content-Type': 'application/json'
}
};
try {
const res = await axios.post('/api/auth/login', formData, config);
dispatch({
type: 'LOGIN_SUCCESS',
payload: res.data
});
loadUser();
} catch (err) {
dispatch({
type: 'LOGIN_FAIL',
payload: err.response?.data?.message || 'Login failed'
});
}
};
// Logout
const logout = () => {
dispatch({ type: 'LOGOUT' });
};
// Clear errors
const clearErrors = () => {
dispatch({ type: 'CLEAR_ERRORS' });
};
return (
<AuthContext.Provider
value={{
accessToken: state.accessToken,
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated,
loading: state.loading,
user: state.user,
error: state.error,
register,
login,
logout,
loadUser,
refreshToken,
clearErrors
}}
>
{children}
</AuthContext.Provider>
);
};
// Update the authReducer.js
case 'TOKEN_REFRESHED':
localStorage.setItem('accessToken', action.payload.accessToken);
localStorage.setItem('refreshToken', action.payload.refreshToken);
return {
...state,
accessToken: action.payload.accessToken,
refreshToken: action.payload.refreshToken,
loading: false
};
Axios Interceptor for Token Refresh
Another approach is to use Axios interceptors to automatically handle token refresh when API calls fail with 401 errors:
// src/utils/api.js
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json'
}
});
// Request interceptor
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor for handling token refresh
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
api.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
// If the error is due to an expired token and we haven't already tried to refresh
if (error.response.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// If we're already refreshing the token, add this request to the queue
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(token => {
originalRequest.headers['Authorization'] = `Bearer ${token}`;
return api(originalRequest);
})
.catch(err => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token available');
}
// Call refresh token endpoint
const res = await axios.post('/api/auth/refresh-token', {
refreshToken
});
const { accessToken, refreshToken: newRefreshToken } = res.data;
// Store the new tokens
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', newRefreshToken);
// Update auth header for original request
originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;
// Process any queued requests
processQueue(null, accessToken);
return api(originalRequest);
} catch (err) {
// If refresh token fails, logout user
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
// Process queued requests with error
processQueue(err, null);
// Redirect to login page
window.location.href = '/login';
return Promise.reject(err);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default api;
Real-world application: This token refresh pattern is commonly used in production applications to provide a seamless user experience while maintaining security. Users can stay logged in for extended periods without needing to manually re-authenticate, while the system still maintains short-lived access tokens for security.
Security Considerations
Token Storage
Where to store authentication tokens is a critical security consideration. Here are the common options:
- localStorage: Easy to use but vulnerable to XSS attacks.
- sessionStorage: Similar to localStorage but cleared when the browser is closed.
- HttpOnly Cookies: Protected from JavaScript access, offering better security against XSS attacks, but can be vulnerable to CSRF attacks without proper protection.
- In-memory storage: Storing tokens in application state, which gets lost on page refresh.
Recommendation: For most applications, HttpOnly cookies with proper CSRF protection offer the best security/usability tradeoff. However, this requires adjusting your backend to handle cookie-based authentication.
Token Security Best Practices
- Use short expiration times for access tokens (15-60 minutes)
- Implement refresh token rotation to prevent the reuse of compromised refresh tokens
- Include necessary claims only in tokens to minimize exposed data
- Use appropriate signing algorithms (like RS256 for asymmetric signing)
- Implement token revocation for logout and security incidents
- Use HTTPS for all communication to prevent token interception
- Consider audience and issuer claims to prevent token misuse across services
Protecting Against Common Attacks
Cross-Site Scripting (XSS): Malicious scripts can access tokens stored in localStorage or sessionStorage.
Protection: Use HttpOnly cookies, implement Content Security Policy (CSP), and sanitize user input.
Cross-Site Request Forgery (CSRF): Attackers trick users into making unwanted requests to your API.
Protection: Use CSRF tokens, validate Origin/Referer headers, and implement SameSite cookie attribute.
Man-in-the-Middle (MITM): Attackers intercept communication between client and server.
Protection: Use HTTPS for all communication, implement HSTS, and use secure/HttpOnly cookie flags.
Token Leakage: Tokens exposed in URL parameters, logs, or browser history.
Protection: Never include tokens in URLs, implement proper error handling, and use secure storage.
Social Authentication Integration
Many applications offer authentication through social providers like Google, Facebook, or GitHub. Let's implement Google authentication using Passport.js:
Backend Implementation
First, install the required packages:
npm install passport passport-google-oauth20
Set up Passport.js with Google strategy:
// config/passport.js
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const User = require('../models/User');
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/api/auth/google/callback',
scope: ['profile', 'email']
},
async (accessToken, refreshToken, profile, done) => {
try {
// Check if user already exists
let user = await User.findOne({ email: profile.emails[0].value });
if (user) {
// User exists, return user
return done(null, user);
}
// Create new user
user = new User({
name: profile.displayName,
email: profile.emails[0].value,
// Generate a random secure password
password: require('crypto').randomBytes(16).toString('hex'),
// Add other fields as needed
});
await user.save();
return done(null, user);
} catch (error) {
return done(error, null);
}
}
)
);
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
try {
const user = await User.findById(id);
done(null, user);
} catch (error) {
done(error, null);
}
});
module.exports = passport;
Add Google authentication routes:
// routes/auth.js
const passport = require('../config/passport');
// Google OAuth routes
router.get(
'/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
router.get(
'/google/callback',
passport.authenticate('google', { session: false }),
(req, res) => {
// Generate JWT token
const token = generateToken(req.user._id);
// Redirect to frontend with token
res.redirect(`${process.env.CLIENT_URL}/auth/success?token=${token}`);
}
);
Update server.js to include Passport:
// server.js
const passport = require('./config/passport');
// Passport middleware
app.use(passport.initialize());
Frontend Implementation
Add a button to initiate Google login:
// src/components/auth/SocialLogin.js
import React from 'react';
const SocialLogin = () => {
const handleGoogleLogin = () => {
window.location.href = '/api/auth/google';
};
return (
<div className="social-login">
<button
onClick={handleGoogleLogin}
className="btn btn-google"
>
<i className="fab fa-google"></i> Login with Google
</button>
</div>
);
};
export default SocialLogin;
Create a component to handle the OAuth callback:
// src/components/auth/AuthCallback.js
import React, { useEffect, useContext } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { AuthContext } from '../../context/auth/AuthContext';
const AuthCallback = () => {
const { loadUser } = useContext(AuthContext);
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
// Get token from URL query params
const query = new URLSearchParams(location.search);
const token = query.get('token');
if (token) {
// Store token in localStorage
localStorage.setItem('token', token);
// Load user data
loadUser();
// Redirect to dashboard
navigate('/dashboard');
} else {
// If no token, redirect to login
navigate('/login');
}
}, [location, loadUser, navigate]);
return <div>Processing authentication...</div>;
};
export default AuthCallback;
Add the callback route to App.js:
// src/App.js
<Route path="/auth/success" element={<AuthCallback />} />
Include the SocialLogin component in your Login component:
// src/components/auth/Login.js
import SocialLogin from './SocialLogin';
// Inside render method
return (
<div className="form-container">
<h1>Account <span className="text-primary">Login</span></h1>
{error && <div className="alert alert-danger">{error}</div>}
<form onSubmit={onSubmit}>
{/* Form fields */}
</form>
<div className="or-divider">
<span>OR</span>
</div>
<SocialLogin />
<p className="my-1">
Don't have an account? <Link to="/register">Register</Link>
</p>
</div>
);
Practice Activities
Activity 1: Basic Authentication Flow
Implement a complete authentication flow with registration, login, and protected routes:
- Set up an Express backend with JWT authentication
- Create a MongoDB user model with password hashing
- Implement a React frontend with Context API for auth state
- Add protected routes that require authentication
- Implement logout functionality
Activity 2: Token Refresh Mechanism
Enhance your authentication system with a token refresh mechanism:
- Implement both access and refresh tokens on the backend
- Add a token refresh endpoint to the API
- Configure the frontend to handle token expiration
- Implement automatic token refresh using Axios interceptors
- Test the system by manipulating token expiration times
Activity 3: Social Authentication
Add social authentication to your application:
- Register your application with a social provider (Google, GitHub, etc.)
- Set up Passport.js on your Express backend
- Implement OAuth routes for authentication
- Create a social login button in your React frontend
- Handle the OAuth callback and JWT issuance
Activity 4: Role-Based Authorization
Implement role-based access control in your application:
- Modify the user model to include roles (user, admin, etc.)
- Create middleware to check user roles for protected routes
- Implement an admin panel accessible only to admin users
- Add role-based UI elements that show/hide based on the user's role
- Create a user management interface for administrators
Summary
- Authentication Fundamentals: Understand the difference between session-based and token-based authentication, with a focus on JWT for MERN applications.
- JWT Implementation: Learn how to generate, validate, and manage JWT tokens for secure authentication.
- Backend Implementation: Create a robust Express.js authentication system with user registration, login, and protected routes.
- Frontend Implementation: Use React Context API and hooks to manage authentication state across the application.
- Token Refresh: Implement token refresh mechanisms to provide a seamless user experience while maintaining security.
- Security Considerations: Understand the security implications of different token storage options and how to protect against common attacks.
- Social Authentication: Integrate third-party authentication providers for a frictionless login experience.
Authentication is a critical component of any web application, ensuring that users can securely access their data and perform authorized actions. By implementing a robust authentication flow in your MERN stack applications, you can provide a secure and user-friendly experience while protecting sensitive information.
Further Resources
- JWT.io - JSON Web Tokens official website with interactive debugger
- jsonwebtoken - Node.js library for handling JWTs
- OWASP Top Ten - Security vulnerabilities to be aware of
- React Context API - Managing global state in React
- Passport.js - Authentication middleware for Node.js
- JWT Authentication Best Practices - Security considerations for JWT
- Refresh Token Rotation - Advanced security pattern for refresh tokens