Authentication Flow in MERN Stack

Building secure user authentication systems in full-stack JavaScript applications

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:

flowchart TD A[User Enters Credentials] --> B{Validate Credentials} B -->|Valid| C[Generate Authentication Token] B -->|Invalid| D[Return Authentication Error] C --> E[Store Token on Client] E --> F[Include Token in Future Requests] F --> G{Verify Token} G -->|Valid| H[Return Protected Resource] G -->|Invalid| I[Return Authorization Error] style A fill:#d4f0f0,stroke:#000 style B fill:#ffeecc,stroke:#000 style C fill:#d4f0f0,stroke:#000 style D fill:#ffdddd,stroke:#000 style E fill:#d4f0f0,stroke:#000 style F fill:#d4f0f0,stroke:#000 style G fill:#ffeecc,stroke:#000 style H fill:#d4f0f0,stroke:#000 style I fill:#ffdddd,stroke:#000

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
sequenceDiagram participant Client participant Server participant Database Note over Client,Server: Session-based Authentication Client->>Server: Login Request with Credentials Server->>Database: Verify Credentials Database-->>Server: Credentials Valid Server->>Server: Create Session Server-->>Client: Set Session Cookie Client->>Server: Request with Session Cookie Server->>Server: Validate Session Server-->>Client: Return Protected Resource Note over Client,Server: Token-based Authentication Client->>Server: Login Request with Credentials Server->>Database: Verify Credentials Database-->>Server: Credentials Valid Server->>Server: Generate JWT Server-->>Client: Return JWT Client->>Client: Store JWT Client->>Server: Request with JWT in Header Server->>Server: Verify JWT Server-->>Client: Return Protected Resource

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:

  1. Header: Contains the token type and signing algorithm
  2. Payload: Contains claims or statements about the user and token
  3. 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.

sequenceDiagram participant User participant Client participant AuthServer as OAuth Provider participant API User->>Client: Click "Login with Google" Client->>AuthServer: Redirect to OAuth Provider User->>AuthServer: Authenticate (if not already) AuthServer->>User: Ask for Permission User->>AuthServer: Grant Permission AuthServer->>Client: Redirect with Authorization Code Client->>AuthServer: Exchange Code for Access Token AuthServer->>Client: Return Access Token Client->>API: Request User Info with Token API->>Client: Return User Info Client->>Client: Create User Session

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;
sequenceDiagram participant User participant React participant AuthContext participant LocalStorage participant Express participant MongoDB User->>React: Enter login credentials React->>Express: POST /api/auth/login Express->>MongoDB: Find user & verify password MongoDB-->>Express: User data Express->>Express: Generate JWT Express-->>React: Return JWT & user data React->>AuthContext: Update auth state AuthContext->>LocalStorage: Store JWT Note over User,React: User navigates to protected route User->>React: Navigate to Dashboard React->>PrivateRoute: Check auth state PrivateRoute->>AuthContext: Is user authenticated? alt User is authenticated AuthContext-->>PrivateRoute: Yes, show protected content PrivateRoute->>React: Render Dashboard React->>Express: GET /api/auth/me Express->>Express: Verify JWT Express->>MongoDB: Get user data MongoDB-->>Express: User data Express-->>React: User data React-->>User: Show Dashboard with user data else User is not authenticated AuthContext-->>PrivateRoute: No, redirect to login PrivateRoute->>React: Redirect to Login React-->>User: Show Login page end Note over User,React: User logs out User->>React: Click Logout React->>AuthContext: Dispatch logout action AuthContext->>LocalStorage: Remove JWT AuthContext->>React: Update auth state React->>React: Redirect to home page React-->>User: Show home page

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:

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

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 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