Implement Full-Stack Authentication with JWT and Refresh Tokens (Node.js + React)

17/07/2025

Implement Full-Stack Authentication with JWT and Refresh Tokens (Node.js + React)

Learn how to implement secure full-stack authentication using JWT and refresh tokens in a Node.js and React app. This guide covers login, token generation, access control, and token refresh logic.

Full-Stack Authentication: Implementing JWT and Refresh Tokens for Secure Sessions

Authentication is a fundamental aspect of almost every modern web application. While session-based authentication has been traditional, token-based authentication, particularly using JSON Web Tokens (JWTs), has gained significant popularity for its stateless nature and scalability. However, relying solely on short-lived JWTs can lead to frequent re-authentication, impacting user experience. This is where the concept of Refresh Tokens comes into play, providing a robust and secure way to manage long-lived user sessions.

This detailed guide will walk you through implementing full-stack authentication using JWTs and Refresh Tokens in a Node.js (Express) backend and a React frontend. We'll cover the entire flow, from user login to token refreshing and secure logout.

Understanding JWTs and Refresh Tokens

Let's clarify the roles of these two tokens:

  • JSON Web Token (JWT) - The Access Token:
    • A compact, URL-safe means of representing claims to be transferred between two parties.
    • Contains user information (e.g., user ID, roles) in its payload.
    • Is signed by the server, ensuring its integrity (cannot be tampered with).
    • Is short-lived (e.g., 15 minutes to 1 hour) to minimize the window of opportunity for attackers if it's compromised.
    • Sent with every request to access protected resources.
  • Refresh Token:
    • A long-lived token (e.g., days, weeks, or months).
    • Used to obtain a new, short-lived Access Token when the current one expires.
    • Stored securely on the client-side (e.g., in an HTTP-only cookie) and in the database on the server-side.
    • Typically sent only to a dedicated refresh endpoint.

This dual-token approach provides a balance between security (short-lived access tokens) and user experience (long-lived sessions without frequent re-login).

Backend Setup: Node.js (Express) for Token Management

The backend will handle user registration, login, token generation, token validation, and token refreshing.

1. Install Dependencies

Navigate to your `server` directory and install the necessary packages:

npm install express mongoose bcryptjs jsonwebtoken cookie-parser dotenv
  • `jsonwebtoken`: For creating and verifying JWTs.
  • `cookie-parser`: Middleware to parse cookies from incoming requests.
  • `bcryptjs`: For hashing passwords.
  • `mongoose`: ODM for MongoDB.
  • `dotenv`: For environment variables.

2. Environment Variables (`.env`)

Create a `.env` file in your `server` directory:

MONGO_URI=your_mongodb_connection_string
JWT_ACCESS_SECRET=your_access_token_secret
JWT_REFRESH_SECRET=your_refresh_token_secret
ACCESS_TOKEN_EXPIRY=15m # e.g., 15 minutes
REFRESH_TOKEN_EXPIRY=7d # e.g., 7 days
CLIENT_URL=http://localhost:3000 # Your React app's URL

3. User Model (`models/User.js`)

Create `server/models/User.js`. We'll add a field to store refresh tokens.

// server/models/User.js

const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');

const UserSchema = new mongoose.Schema({
    username: {
        type: String,
        required: true,
        unique: true,
    },
    email: {
        type: String,
        required: true,
        unique: true,
    },
    password: {
        type: String,
        required: true,
    },
    refreshTokens: [String], // Array to store multiple refresh tokens per user
});

// Hash password before saving
UserSchema.pre('save', async function (next) {
    if (!this.isModified('password')) {
        next();
    }
    const salt = await bcrypt.genSalt(10);
    this.password = await bcrypt.hash(this.password, salt);
    next();
});

// Method to compare passwords
UserSchema.methods.matchPassword = async function (enteredPassword) {
    return await bcrypt.compare(enteredPassword, this.password);
};

module.exports = mongoose.model('User', UserSchema);

4. Authentication Middleware (`middleware/authMiddleware.js`)

Create `server/middleware/authMiddleware.js` to protect routes:

// server/middleware/authMiddleware.js

const jwt = require('jsonwebtoken');
const User = require('../models/User');

const protect = async (req, res, next) => {
    let token;

    // Check for access token in Authorization header
    if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
        try {
            token = req.headers.authorization.split(' ')[1];

            // Verify token
            const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET);

            // Attach user to the request object
            req.user = await User.findById(decoded.id).select('-password -refreshTokens'); // Exclude sensitive fields
            next();
        } catch (error) {
            console.error('Access token verification failed:', error);
            res.status(401).json({ message: 'Not authorized, access token failed' });
        }
    }

    if (!token) {
        res.status(401).json({ message: 'Not authorized, no access token' });
    }
};

module.exports = { protect };

5. Server Routes (`server.js`)

Modify your `server/server.js` to include authentication routes:

// server/server.js

const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const cookieParser = require('cookie-parser'); // Import cookie-parser
const User = require('./models/User');
const { protect } = require('./middleware/authMiddleware'); // Import auth middleware
require('dotenv').config();

const app = express();
const port = process.env.PORT || 5000;

// Middleware
app.use(cors({
    origin: process.env.CLIENT_URL, // Allow requests from your React app
    credentials: true, // Allow sending cookies
}));
app.use(express.json());
app.use(cookieParser()); // Use cookie-parser middleware

// MongoDB Connection
mongoose.connect(process.env.MONGO_URI)
    .then(() => console.log('MongoDB connected'))
    .catch(err => console.error(err));

// Helper function to generate tokens
const generateTokens = (id) => {
    const accessToken = jwt.sign({ id }, process.env.JWT_ACCESS_SECRET, {
        expiresIn: process.env.ACCESS_TOKEN_EXPIRY, // e.g., '15m'
    });
    const refreshToken = jwt.sign({ id }, process.env.JWT_REFRESH_SECRET, {
        expiresIn: process.env.REFRESH_TOKEN_EXPIRY, // e.g., '7d'
    });
    return { accessToken, refreshToken };
};

// --- User Registration Route ---
app.post('/api/register', async (req, res) => {
    const { username, email, password } = req.body;

    try {
        let user = await User.findOne({ email });
        if (user) {
            return res.status(400).json({ message: 'User already exists' });
        }

        user = new User({ username, email, password });
        await user.save();

        const { accessToken, refreshToken } = generateTokens(user._id);

        // Store refresh token in database
        user.refreshTokens.push(refreshToken);
        await user.save();

        // Set refresh token as HTTP-only cookie
        res.cookie('refreshToken', refreshToken, {
            httpOnly: true, // Accessible only by web server
            secure: process.env.NODE_ENV === 'production', // Use secure in production
            sameSite: 'Lax', // or 'Strict'
            maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
        });

        res.status(201).json({
            message: 'User registered successfully',
            accessToken,
            user: { id: user._id, username: user.username, email: user.email },
        });

    } catch (error) {
        console.error('Registration error:', error);
        res.status(500).json({ message: 'Server error during registration.', error: error.message });
    }
});

// --- User Login Route ---
app.post('/api/login', async (req, res) => {
    const { email, password } = req.body;

    try {
        const user = await User.findOne({ email });
        if (!user) {
            return res.status(400).json({ message: 'Invalid credentials' });
        }

        const isMatch = await user.matchPassword(password);
        if (!isMatch) {
            return res.status(400).json({ message: 'Invalid credentials' });
        }

        const { accessToken, refreshToken } = generateTokens(user._id);

        // Add new refresh token to user's refreshTokens array
        user.refreshTokens.push(refreshToken);
        await user.save();

        // Set refresh token as HTTP-only cookie
        res.cookie('refreshToken', refreshToken, {
            httpOnly: true,
            secure: process.env.NODE_ENV === 'production',
            sameSite: 'Lax',
            maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
        });

        res.status(200).json({
            message: 'Logged in successfully',
            accessToken,
            user: { id: user._id, username: user.username, email: user.email },
        });

    } catch (error) {
        console.error('Login error:', error);
        res.status(500).json({ message: 'Server error during login.', error: error.message });
    }
});

// --- Refresh Token Route ---
app.post('/api/refresh-token', async (req, res) => {
    const refreshToken = req.cookies.refreshToken;

    if (!refreshToken) {
        return res.status(401).json({ message: 'No refresh token provided' });
    }

    try {
        // Verify refresh token
        const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);

        // Find user by ID and check if refresh token exists in DB
        const user = await User.findById(decoded.id);
        if (!user || !user.refreshTokens.includes(refreshToken)) {
            // If token is not found or not associated with user, clear cookie and deny
            res.clearCookie('refreshToken', { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'Lax' });
            return res.status(403).json({ message: 'Invalid refresh token. Please log in again.' });
        }

        // Generate new access token
        const newAccessToken = jwt.sign({ id: user._id }, process.env.JWT_ACCESS_SECRET, {
            expiresIn: process.env.ACCESS_TOKEN_EXPIRY,
        });

        res.status(200).json({ accessToken: newAccessToken });

    } catch (error) {
        console.error('Refresh token error:', error);
        res.status(403).json({ message: 'Invalid or expired refresh token. Please log in again.' });
    }
});

// --- User Logout Route ---
app.post('/api/logout', async (req, res) => {
    const refreshToken = req.cookies.refreshToken;

    if (!refreshToken) {
        return res.status(204).json({ message: 'No refresh token to clear' }); // No content
    }

    try {
        // Find user by refresh token and remove it from DB
        const user = await User.findOne({ refreshTokens: refreshToken });
        if (user) {
            user.refreshTokens = user.refreshTokens.filter(token => token !== refreshToken);
            await user.save();
        }

        // Clear the HTTP-only cookie
        res.clearCookie('refreshToken', { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'Lax' });
        res.status(200).json({ message: 'Logged out successfully' });

    } catch (error) {
        console.error('Logout error:', error);
        res.status(500).json({ message: 'Server error during logout.', error: error.message });
    }
});

// --- Protected Route Example ---
app.get('/api/protected', protect, (req, res) => {
    res.status(200).json({
        message: `Welcome, ${req.user.username}! This is a protected resource.`,
        user: req.user,
    });
});

// Start the server
app.listen(port, () => {
    console.log(`Server running on port ${port}`);
});
```

Explanation of Backend Code:

  • `generateTokens(id)`: Helper to create both access and refresh tokens.
  • `cookie-parser` & `res.cookie()`: Used to set the refresh token as an HTTP-only cookie. This is a common and secure way to store refresh tokens on the client-side, as it prevents JavaScript from accessing them, mitigating XSS attacks.
  • `/api/register` & `/api/login`:
    • Generate both tokens upon successful registration/login.
    • Store the refresh token in the user's document in MongoDB (for revocation/tracking).
    • Send the access token in the JSON response to the frontend.
    • Set the refresh token as an HTTP-only cookie.
  • `/api/refresh-token`:
    • Retrieves the refresh token from the HTTP-only cookie.
    • Verifies the refresh token.
    • Checks if the token is valid and exists in the user's `refreshTokens` array in the database.
    • If valid, issues a *new* access token.
  • `/api/logout`:
    • Retrieves the refresh token from the cookie.
    • Removes that specific refresh token from the user's document in the database.
    • Clears the HTTP-only cookie on the client-side.
  • `protect` middleware:
    • Extracts the access token from the `Authorization: Bearer` header.
    • Verifies the access token using `jwt.verify()`.
    • If valid, attaches the `user` object to the `req` object, allowing subsequent route handlers to access user data.

Frontend Setup: React for Token Management

Your React frontend will handle storing the access token (e.g., in local storage or a Redux store), attaching it to requests, and implementing the refresh token logic.

1. Install Dependencies (if not already)

Navigate to your `client` directory:

npm install react-router-dom axios
  • `react-router-dom`: For routing.
  • `axios`: A popular promise-based HTTP client, often preferred over `fetch` for its features, including interceptors.

2. Axios Instance with Interceptors (`axiosInstance.js`)

This is crucial for automatically attaching access tokens and handling token refreshes. Create `client/src/utils/axiosInstance.js`:

// client/src/utils/axiosInstance.js

import axios from 'axios';

const axiosInstance = axios.create({
  baseURL: 'http://localhost:5000/api', // Your backend API base URL
  withCredentials: true, // Crucial for sending HTTP-only cookies (refresh token)
});

// Request Interceptor: Attach access token to outgoing requests
axiosInstance.interceptors.request.use(
  (config) => {
    const accessToken = localStorage.getItem('accessToken'); // Get access token from local storage
    if (accessToken) {
      config.headers.Authorization = `Bearer ${accessToken}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// Response Interceptor: Handle expired access tokens and refresh them
axiosInstance.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    // If the error is 401 (Unauthorized) and it's not the refresh token endpoint itself
    // and we haven't already tried to refresh for this request
    if (error.response.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true; // Mark as retried to prevent infinite loops

      try {
        // Call the refresh token endpoint
        const response = await axiosInstance.post('/refresh-token'); // No need for full URL, baseURL is set
        const newAccessToken = response.data.accessToken;

        // Store the new access token
        localStorage.setItem('accessToken', newAccessToken);

        // Update the original request's header with the new token
        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;

        // Retry the original request with the new access token
        return axiosInstance(originalRequest);
      } catch (refreshError) {
        // If refresh fails (e.g., refresh token expired or invalid),
        // clear tokens and redirect to login
        localStorage.removeItem('accessToken');
        // You might want to dispatch a logout action here if using Redux
        window.location.href = '/login'; // Redirect to login page
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

export default axiosInstance;

Explanation of `axiosInstance.js`:

  • `baseURL`: Sets the base URL for all requests, making API calls cleaner.
  • `withCredentials: true`: Essential for Axios to send and receive HTTP-only cookies (our refresh token).
  • Request Interceptor: Before any request is sent, it checks for an `accessToken` in local storage and adds it to the `Authorization` header.
  • Response Interceptor:
    • If a response returns a `401 Unauthorized` status (meaning the access token is expired or invalid), it attempts to refresh the token.
    • It makes a request to `/api/refresh-token` (which automatically sends the HTTP-only refresh cookie).
    • If successful, it updates the `accessToken` in local storage and retries the original failed request with the new token.
    • If the refresh fails, it clears the access token and redirects the user to the login page.

3. Login Component (`Login.js`)

Create `client/src/components/Login.js`:

// client/src/components/Login.js

import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import axiosInstance from '../utils/axiosInstance'; // Use the configured Axios instance

function Login() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [message, setMessage] = useState('');
  const [loading, setLoading] = useState(false);
  const navigate = useNavigate();

  const handleSubmit = async (e) => {
    e.preventDefault();
    setLoading(true);
    setMessage('');

    try {
      const response = await axiosInstance.post('/login', { email, password }); // Use axiosInstance

      localStorage.setItem('accessToken', response.data.accessToken); // Store access token
      // You might also store user info: localStorage.setItem('user', JSON.stringify(response.data.user));

      setMessage(response.data.message);
      navigate('/dashboard'); // Redirect to a protected dashboard page
    } catch (error) {
      console.error('Login error:', error.response?.data || error.message);
      setMessage(error.response?.data?.message || 'Login failed.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div style={{
      backgroundColor: '#fff',
      padding: '30px',
      borderRadius: '10px',
      boxShadow: '0 4px 10px rgba(0,0,0,0.1)',
      width: '100%',
      maxWidth: '500px',
      margin: '20px auto',
      boxSizing: 'border-box',
      textAlign: 'left'
    }}>
      <h2 style={{
        fontFamily: 'Merriweather, serif',
        color: '#222',
        fontSize: '1.8em',
        marginBottom: '20px',
        borderBottom: '2px solid #eee',
        paddingBottom: '10px',
        boxSizing: 'border-box'
      }}>Login</h2>
      <form onSubmit={handleSubmit} style={{display: 'flex', flexDirection: 'column', gap: '15px', boxSizing: 'border-box'}}>
        <div style={{boxSizing: 'border-box'{{>
          <label htmlFor="email" style={{display: 'block', marginBottom: '5px', fontWeight: 'bold', color: '#555', boxSizing: 'border-box'}}>Email:</label>
          <input
            type="email"
            id="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
            style={{width: '100%', padding: '10px', border: '1px solid #ddd', borderRadius: '5px', boxSizing: 'border-box'}}
          />
        </div>
        <div style={{boxSizing: 'border-box'{{>
          <label htmlFor="password" style={{display: 'block', marginBottom: '5px', fontWeight: 'bold', color: '#555', boxSizing: 'border-box'}}>Password:</label>
          <input
            type="password"
            id="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
            style={{width: '100%', padding: '10px', border: '1px solid #ddd', borderRadius: '5px', boxSizing: 'border-box'}}
          />
        </div>
        <button type="submit" disabled={loading} style={{
          backgroundColor: loading ? '#ccc' : '#c00',
          color: '#fff',
          padding: '12px 25px',
          border: 'none',
          borderRadius: '8px',
          cursor: loading ? 'not-allowed' : 'pointer',
          fontSize: '1em',
          fontWeight: 'bold',
          transition: 'background-color 0.3s ease',
          boxShadow: '0 2px 5px rgba(0,0,0,0.2)',
          boxSizing: 'border-box'
        }}>
          {loading ? 'Logging In...' : 'Login'}
        </button>
      </form>
      {message && (
        <p style={{
          marginTop: '20px',
          color: message.includes('successfully') ? 'green' : 'red',
          fontSize: '1em',
          boxSizing: 'border-box'
        }}>
          {message}
        </p>
      )}
    </div>
  );
}

export default Login;
```

4. Protected Dashboard Component (`Dashboard.js`)

Create `client/src/components/Dashboard.js` to test the protected route:

// client/src/components/Dashboard.js

import React, { useEffect, useState } => {
import axiosInstance from '../utils/axiosInstance';
import { useNavigate } from 'react-router-dom';

function Dashboard() {
  const [data, setData] = useState(null);
  const [message, setMessage] = useState('Loading protected data...');
  const navigate = useNavigate();

  useEffect(() => {
    const fetchProtectedData = async () => {
      try {
        const response = await axiosInstance.get('/protected'); // Use axiosInstance
        setData(response.data.user);
        setMessage(response.data.message);
      } catch (error) {
        console.error('Protected data error:', error.response?.data || error.message);
        setMessage(error.response?.data?.message || 'Failed to fetch protected data.');
        // If 401/403, the interceptor should handle redirecting to login
      }
    };

    fetchProtectedData();
  }, []);

  const handleLogout = async () => {
    try {
      await axiosInstance.post('/logout'); // Call logout endpoint
      localStorage.removeItem('accessToken'); // Clear access token from local storage
      setMessage('Logged out successfully.');
      navigate('/login'); // Redirect to login page
    } catch (error) {
      console.error('Logout error:', error.response?.data || error.message);
      setMessage(error.response?.data?.message || 'Logout failed.');
    }
  };

  return (
    <div style={{
      backgroundColor: '#fff',
      padding: '30px',
      borderRadius: '10px',
      boxShadow: '0 4px 10px rgba(0,0,0,0.1)',
      width: '100%',
      maxWidth: '600px',
      margin: '20px auto',
      boxSizing: 'border-box',
      textAlign: 'left'
    }}>
      <h2 style={{
        fontFamily: 'Merriweather, serif',
        color: '#222',
        fontSize: '1.8em',
        marginBottom: '20px',
        borderBottom: '2px solid #eee',
        paddingBottom: '10px',
        boxSizing: 'border-box'
      }}>Dashboard</h2>
      <p style={{color: '#555', marginBottom: '15px', boxSizing: 'border-box'}}>{message}</p>
      {data && (
        <div style={{borderTop: '1px solid #eee', paddingTop: '15px', boxSizing: 'border-box'}}>
          <h3 style={{fontFamily: 'Merriweather, serif', fontSize: '1.4em', color: '#007bff', marginBottom: '10px', boxSizing: 'border-box'}}>User Info:</h3>
          <p style={{marginBottom: '5px', boxSizing: 'border-box'}}><strong style={{boxSizing: 'border-box'}}>Username:</strong> {data.username}</p>
          <p style={{boxSizing: 'border-box'}}><strong style={{boxSizing: 'border-box'}}>Email:</strong> {data.email}</p>
        </div>
      )}
      <button onClick={handleLogout} style={{
        backgroundColor: '#dc3545',
        color: '#fff',
        padding: '12px 25px',
        border: 'none',
        borderRadius: '8px',
        cursor: 'pointer',
        fontSize: '1em',
        fontWeight: 'bold',
        transition: 'background-color 0.3s ease',
        boxShadow: '0 2px 5px rgba(0,0,0,0.2)',
        marginTop: '30px',
        boxSizing: 'border-box'
      }}>Logout</button>
    </div>
  );
}

export default Dashboard;

5. Update `App.js` for Routing

Modify `client/src/App.js` to set up basic routing for your components:

// client/src/App.js

import React from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import Register from './components/Register';
import Login from './components/Login';
import Dashboard from './components/Dashboard';

function App() {
  return (
    <Router>
      <div style={{
        fontFamily: 'Open Sans, sans-serif',
        textAlign: 'center',
        padding: '40px',
        backgroundColor: '#f0f2f5',
        minHeight: '100vh',
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'center',
        alignItems: 'center',
        boxSizing: 'border-box'
      }}>
        <h1 style={{
          fontFamily: 'Merriweather, serif',
          color: '#c00',
          fontSize: '3em',
          marginBottom: '20px',
          boxSizing: 'border-box'
        }}>JWT Auth Demo</h1>

        <nav style={{marginBottom: '30px', boxSizing: 'border-box'}}>
          <ul style={{listStyle: 'none', padding: '0', margin: '0', display: 'flex', gap: '20px', boxSizing: 'border-box'}}>
            <li style={{boxSizing: 'border-box'}}><Link to="/register" style={{
              color: '#007bff', textDecoration: 'none', fontSize: '1.1em', fontWeight: 'bold', boxSizing: 'border-box'
            }}>Register</Link></li>
            <li style={{boxSizing: 'border-box'}}><Link to="/login" style={{
              color: '#007bff', textDecoration: 'none', fontSize: '1.1em', fontWeight: 'bold', boxSizing: 'border-box'
            }}>Login</Link></li>
            <li style={{boxSizing: 'border-box'}}><Link to="/dashboard" style={{
              color: '#007bff', textDecoration: 'none', fontSize: '1.1em', fontWeight: 'bold', boxSizing: 'border-box'
            }}>Dashboard</Link></li>
          </ul>
        </nav>

        <Routes>
          <Route path="/register" element={<Register />} />
          <Route path="/login" element={<Login />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/" element={<Login />} /> {/* Default route */}
        </Routes>
      </div>
    </Router>
  );
}

export default App;

Authentication Flow: JWT and Refresh Tokens

Here's a summary of the authentication flow with JWTs and Refresh Tokens:

  1. User Logs In (React): User submits credentials via the `Login` component.
  2. Backend Issues Tokens (Node.js):
    • Upon successful login, the backend generates a short-lived Access Token and a long-lived Refresh Token.
    • The Access Token is sent in the JSON response.
    • The Refresh Token is set as an HTTP-only cookie in the response.
    • The Refresh Token is also stored in the database, associated with the user.
  3. Frontend Stores Access Token (React): The React app stores the Access Token (e.g., in `localStorage`). The Refresh Token is automatically handled by the browser as an HTTP-only cookie.
  4. Accessing Protected Resources (React to Node.js): For every subsequent request to a protected route, the Axios request interceptor attaches the Access Token from `localStorage` to the `Authorization: Bearer` header.
  5. Access Token Expiration & Refresh (Axios Interceptor & Node.js):
    • If the Access Token expires, the backend responds with `401 Unauthorized`.
    • The Axios response interceptor catches this `401` error.
    • It then automatically sends a request to the `/api/refresh-token` endpoint. The HTTP-only refresh token cookie is automatically sent with this request.
    • The backend validates the refresh token against the database. If valid, it issues a *new* Access Token.
    • The new Access Token is returned to the frontend, stored in `localStorage`, and the original failed request is retried.
  6. Logout (React to Node.js): When the user logs out, the frontend sends a request to `/api/logout`. The backend removes the refresh token from the database and clears the HTTP-only cookie, effectively ending the session.

Conclusion

Implementing full-stack authentication with JWTs and Refresh Tokens provides a robust and scalable solution for managing user sessions in modern web applications. This dual-token strategy balances security (short-lived access tokens) with user experience (long-lived sessions). By carefully handling token generation, storage, validation, and refreshing on both the Node.js backend and React frontend, you can build a secure and seamless authentication system that protects your application's resources while providing a smooth user journey.