Building Secure JWT Authentication: React Context + HTTP-Only Cookies

Building a Secure JWT Authentication System with React and Express


Cybertechmind solutions for Building Secure JWT Authentication: React Context + HTTP-Only Cookies

Introduction

    Did you know that a single line of code could leave your application wide open to malicious attacks? Authentication forms the foundation of modern web applications. Yet, implementing it securely demands careful thought about where tokens are stored, how they're automatically refreshed, and the proper way to handle requests. This comprehensive guide walks you through building a production-ready JWT authentication system that tackles common security challenges while delivering a smooth user experience.

    Many tutorials make the mistake of storing JWT tokens in localStorage, which leaves them vulnerable to XSS attacks. When cross-site scripting vulnerabilities exist, attackers can steal tokens and impersonate users. We need a better approach—one that treats security as a fundamental requirement, not an afterthought.

The Problem with Traditional Approaches

    LocalStorage is certainly convenient to work with—just a simple localStorage.setItem('token', 'mytoken') and you're done. It offers persistence and plenty of storage space (around 5MB). But this ease of use comes with a serious security trade-off: localStorage provides absolutely no defense against Cross-Site Scripting (XSS) attacks.

Understanding the Real Threat

    Picture this scenario: someone manages to inject a malicious script into your website. This could happen through a compromised third-party library, an insecure input field, or any number of attack vectors. Once that script is running, it can easily access everything stored in localStorage, package it up, and transmit it to an attacker's server. Just like that, your users' tokens—and their accounts—are compromised.

    Some developers believe they can solve this by encrypting the JWT before placing it in localStorage. Unfortunately, this doesn't address the core issue. Since the decryption key would also need to be stored somewhere accessible to your JavaScript, it provides no real protection against XSS attacks.

❌ CRITICAL SECURITY FLAW
localStorage.setItem('token', token); // Accessible to ANY script!
localStorage.setItem('encryptedToken', encrypt(token)); // Still vulnerable!

The fundamental problem: tokens stored in localStorage are like leaving your house keys under the doormat.

Our Solution: A Hybrid Token Strategy

 

Cybertechmind solutions for Secure JWT Auth with HTTP-Only Cookies


We'll implement a dual-token system similar to what major companies like Google and Meta use—an approach that balances security with user experience:

  1. Access Token - Short-lived (15 minutes), kept in React Context memory
  2. Refresh Token - Long-lived (7 days), secured in HTTP-only cookies

This strategy delivers:

  • ✅ Strong protection against XSS attacks (refresh token stays out of JavaScript's reach)
  • ✅ Defense against CSRF through SameSite cookie attributes
  • ✅ Automatic token refresh before expiration
  • ✅ Request queueing during token refresh
  • ✅ Seamless session restoration on page refresh

 

Why HTTP-Only Cookies Are Your Security Foundation

While cookies have sometimes been viewed skeptically, they represent battle-tested security when used correctly. For sensitive JWT storage, HTTP-only cookies are the gold standard.

The Power of HTTP-Only and SameSite

The HTTP-Only Flag: When your server responds with a Set-Cookie header that includes the httpOnly flag, the browser treats that cookie like a secure vault. Neither your JavaScript code nor any malicious script can access it. This creates a powerful defense layer against XSS attacks—even if an attacker manages to inject code, they can't steal the token.

The SameSite Attribute: Concerned about Cross-Site Request Forgery (CSRF)? The SameSite attribute revolutionized cookie security. By setting it to Lax or Strict, you tell the browser to only include the cookie when requests come from your own domain. This effectively blocks the most common CSRF attack patterns.

✅ SECURE APPROACH
res.cookie('refreshToken', token, {
  httpOnly: true,    // JavaScript cannot access it
  secure: true,      // HTTPS only
  sameSite: 'strict' // CSRF protection
});

 

The Hybrid Pattern Explained

For enterprise-grade security combined with great user experience, the hybrid approach is what professionals use. Here's how it works:

Short-lived Access Token in Memory: Store your access token as a JavaScript variable that only exists in memory—when the browser tab closes, it's gone. In React applications, Context provides the perfect mechanism for managing this ephemeral token. It's available to any component that needs it (for checking roles, displaying user info, etc.), but since it only lives in memory, it poses minimal XSS risk.

Long-lived Refresh Token in Secure Cookies: Your refresh token lives in an HTTP-only cookie, completely inaccessible to JavaScript. When your React app loads, it can request the server to verify this secure cookie. If valid, the server issues a fresh short-lived access token back to your React Context, seamlessly restoring the user's session.

This pattern provides fast performance while maintaining tight security.

 

Architecture Overview

 

 

Backend Implementation

Setting Up the Express Server

Let's build a secure Express backend where the server handles token security responsibility.

const express = require('express');
const jwt = require('jsonwebtoken');
const cookieParser = require('cookie-parser');
const cors = require('cors');

const app = express();

// Configuration
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
const ACCESS_TOKEN_EXPIRY = '15m'; // Short-lived
const REFRESH_TOKEN_EXPIRY = '7d'; // Long-lived

// Middleware
app.use(express.json());
app.use(cookieParser());
app.use(cors({
  origin: 'http://localhost:3000',
  credentials: true // Critical for cookies!
}));

[!IMPORTANT] In production, always use environment variables for secrets. Hardcoding API keys or secrets is a critical security violation!

 

Token Generation Functions

function generateAccessToken(user) {
  return jwt.sign(
    { id: user.id, username: user.username },
    ACCESS_TOKEN_SECRET,
    { expiresIn: ACCESS_TOKEN_EXPIRY }
  );
}

function generateRefreshToken(user) {
  return jwt.sign(
    { id: user.id },
    REFRESH_TOKEN_SECRET,
    { expiresIn: REFRESH_TOKEN_EXPIRY }
  );
}

 

Login Endpoint

The login endpoint validates user credentials and establishes both tokens. Notice how the refresh token never appears in the response body—instead, it's transmitted as a secure cookie:

app.post('/api/auth/login', async (req, res) => {
  const { username, password } = req.body;

  // Find user (in production, query your database)
  const user = users.find(u => u.username === username);
  if (!user || password !== user.password) {
    return res.status(401).json({ message: 'Invalid credentials' });
  }

  const accessToken = generateAccessToken(user);
  const refreshToken = generateRefreshToken(user);

  // Set refresh token as HTTP-only cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true, // Cannot be accessed via JavaScript
    secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
    sameSite: 'strict', // CSRF protection
    maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
  });

  // Send access token in response body
  res.json({
    accessToken,
    user: { id: user.id, username: user.username }
  });
});

[!TIP] Here's the beauty of cookies: the refresh token automatically accompanies every request to the same domain. Your frontend doesn't need to manually attach it—the browser handles the secure attachment and protection automatically!

 

Token Refresh Endpoint

This endpoint trades a valid refresh token for a new access token:

app.post('/api/auth/refresh', (req, res) => {
  const refreshToken = req.cookies.refreshToken;

  if (!refreshToken) {
    return res.status(401).json({ message: 'Refresh token not found' });
  }

  jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, decoded) => {
    if (err) {
      return res.status(403).json({ message: 'Invalid refresh token' });
    }

    const user = users.find(u => u.id === decoded.id);
    if (!user) {
      return res.status(403).json({ message: 'User not found' });
    }

    // Generate new access token
    const accessToken = generateAccessToken(user);
    res.json({ accessToken });
  });
});

 

Protected Route Middleware

function verifyToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (!token) {
    return res.status(401).json({ message: 'Access token required' });
  }

  jwt.verify(token, ACCESS_TOKEN_SECRET, (err, decoded) => {
    if (err) {
      return res.status(403).json({ message: 'Invalid or expired token' });
    }
    req.user = decoded;
    next();
  });
}

// Example protected route
app.get('/api/protected', verifyToken, (req, res) => {
  res.json({ 
    message: 'This is protected data', 
    user: req.user 
  });
});

 

Logout Endpoint

app.post('/api/auth/logout', (req, res) => {
  // Clear the refresh token cookie
  res.clearCookie('refreshToken', {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict'
  });
  
  res.json({ message: 'Logged out successfully' });
});

 

Frontend Implementation

Creating the Axios Instance

We'll create two separate Axios instances with distinct responsibilities:

  • authAPI - Handles authentication endpoints (no interceptor)
  • api - Manages protected endpoints (with automatic token handling and refresh)
import axios from 'axios';

const BASE_URL = 'http://localhost:5000/api';

// Auth endpoints (no interceptor needed)
export const authAPI = axios.create({
  baseURL: BASE_URL,
  withCredentials: true, // Send cookies with requests
  headers: { 'Content-Type': 'application/json' }
});

// Protected endpoints (with interceptor)
export const api = axios.create({
  baseURL: BASE_URL,
  withCredentials: true,
  headers: { 'Content-Type': 'application/json' }
});

 

Request Interceptor: Automatic Token Attachment

Rather than manually adding the Authorization header to each request, we use an interceptor. This approach keeps your code clean and ensures consistency:

// Store auth functions (set later by AuthContext)
let getAccessTokenFn = null;
let refreshAccessTokenFn = null;
let logoutFn = null;

export const setupInterceptors = (getAccessToken, refreshAccessToken, logout) => {
  getAccessTokenFn = getAccessToken;
  refreshAccessTokenFn = refreshAccessToken;
  logoutFn = logout;
};

// Request interceptor - automatically attach token
api.interceptors.request.use(
  (config) => {
    if (getAccessTokenFn) {
      const token = getAccessTokenFn();
      if (token) {
        config.headers.Authorization = `Bearer ${token}`;
        console.log('🔑 Token attached to:', config.url);
      }
    }
    return config;
  },
  (error) => Promise.reject(error)
);

 

Response Interceptor: Automatic Token Refresh

Here's where the real power lies. When a request fails with a 401 (unauthorized) response, we automatically refresh the token and retry the request—completely transparent to the user:

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) => response,
  async (error) => {
    const originalRequest = error.config;

    // Handle 401 errors
    if (error.response?.status === 401 && !originalRequest._retry) {
      
      // If refresh is already in progress, queue this request
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        })
          .then(token => {
            originalRequest.headers.Authorization = `Bearer ${token}`;
            return api(originalRequest);
          })
          .catch(err => Promise.reject(err));
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        // Refresh the access token
        const newAccessToken = await refreshAccessTokenFn();
        
        // Update the failed request with new token
        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;

        // Process all queued requests
        processQueue(null, newAccessToken);
        isRefreshing = false;

        // Retry the original request
        return api(originalRequest);
      } catch (refreshError) {
        // If refresh fails, logout user
        processQueue(refreshError, null);
        isRefreshing = false;
        
        if (logoutFn) {
          logoutFn();
        }
        
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

[!NOTE] The Importance of Request Queueing: Without queueing, if 5 requests fail at the same time, you'd trigger 5 separate refresh attempts. Our implementation solves this—only one refresh call happens, and all pending requests wait for and use the new token. This prevents race conditions and unnecessary server load.

 

React Context for Authentication State

React Context is perfect for managing the short-lived access token. We store it as a simple JavaScript variable in memory that vanishes when the browser tab closes. This gives us fast access for UI operations (checking user roles, displaying user info) while minimizing XSS exposure:

import React, { createContext, useState, useContext, useEffect, useRef, useCallback } from 'react';
import { authAPI } from '../api/axios';

const AuthContext = createContext(null);

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const [accessToken, setAccessToken] = useState(null);
  const [loading, setLoading] = useState(true);
  const refreshTimerRef = useRef(null);

  // Login function
  const login = async (username, password) => {
    try {
      const response = await authAPI.post('/auth/login', {
        username,
        password
      });

      const { accessToken: newToken, user: userData } = response.data;
      
      setAccessToken(newToken);
      setUser(userData);

      console.log('✅ Login successful');
      return { success: true };
    } catch (error) {
      return {
        success: false,
        message: error.response?.data?.message || 'Login failed'
      };
    }
  };

  // Refresh access token
  const refreshAccessToken = useCallback(async () => {
    try {
      const response = await authAPI.post('/auth/refresh');
      const { accessToken: newToken } = response.data;
      
      setAccessToken(newToken);
      console.log('🔄 Access token refreshed');
      
      return newToken;
    } catch (error) {
      console.error('❌ Token refresh failed:', error);
      throw error;
    }
  }, []);

  // Logout function
  const logout = useCallback(async () => {
    try {
      await authAPI.post('/auth/logout');
    } catch (error) {
      console.error('❌ Logout error:', error);
    } finally {
      setAccessToken(null);
      setUser(null);
      
      if (refreshTimerRef.current) {
        clearInterval(refreshTimerRef.current);
      }
    }
  }, []);

  // Auto-refresh token every 5 minutes
  useEffect(() => {
    if (accessToken) {
      startRefreshTimer();
    }
    return () => {
      if (refreshTimerRef.current) {
        clearInterval(refreshTimerRef.current);
      }
    };
  }, [accessToken]);

  const startRefreshTimer = () => {
    if (refreshTimerRef.current) {
      clearInterval(refreshTimerRef.current);
    }

    console.log('⏱️ Started auto-refresh timer (5 minutes)');

    refreshTimerRef.current = setInterval(async () => {
      try {
        await refreshAccessToken();
        console.log('✅ Token auto-refreshed successfully');
      } catch (error) {
        console.error('❌ Auto-refresh failed, logging out');
        logout();
      }
    }, 5 * 60 * 1000); // 5 minutes
  };

  // Restore session on mount
  useEffect(() => {
    const restoreSession = async () => {
      try {
        // Try to refresh token (checks if refresh token cookie exists)
        const response = await authAPI.post('/auth/refresh');
        const { accessToken: newToken } = response.data;
        
        if (newToken) {
          // Fetch user details
          const userResponse = await authAPI.get('/user/me', {
            headers: { Authorization: `Bearer ${newToken}` }
          });
          
          setAccessToken(newToken);
          setUser(userResponse.data);
          console.log('✅ Session restored');
        }
      } catch (error) {
        console.log('ℹ️ No active session found');
      } finally {
        setLoading(false);
      }
    };

    restoreSession();
  }, []);

  return (
    <AuthContext.Provider value={{
      user,
      accessToken,
      loading,
      login,
      logout,
      refreshAccessToken
    }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
};

 

Connecting Everything in App.js

import React, { useEffect, useRef } from 'react';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { setupInterceptors } from './api/axios';

function AppContent() {
  const { user, accessToken, loading, refreshAccessToken, logout } = useAuth();
  const interceptorsSetup = useRef(false);

  // Setup Axios interceptors once
  useEffect(() => {
    if (!interceptorsSetup.current) {
      setupInterceptors(
        () => accessToken,
        refreshAccessToken,
        logout
      );
      interceptorsSetup.current = true;
    }
  }, [accessToken, refreshAccessToken, logout]);

  if (loading) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      {user ? <Dashboard /> : <Login />}
    </div>
  );
}

function App() {
  return (
    <AuthProvider>
      <AppContent />
    </AuthProvider>
  );
}

export default App;

 

Security Best Practices

 

1. Never Store Refresh Tokens in localStorage

❌ BAD: localStorage.setItem('refreshToken', token);
✅ GOOD: HTTP-only cookie (not accessible via JavaScript)

 

2. Use Short-lived Access Tokens

const ACCESS_TOKEN_EXPIRY = '15m'; // 15 minutes

Short expiration times limit the window of opportunity if an access token gets compromised.

 

3. Enable CORS Properly

app.use(cors({
  origin: 'http://localhost:3000',
  credentials: true // Required for cookies
}));

 

4. Use HTTPS in Production

res.cookie('refreshToken', token, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production', // HTTPS only
  sameSite: 'strict'
});

 

5. Implement Request Queueing

Without proper queueing, multiple simultaneous failed requests can trigger multiple refresh attempts, creating race conditions and degraded performance. Our implementation handles this elegantly.

Post a Comment

Post a Comment (0)

Previous Post Next Post