Ultimate Guide to Securing JWT Authentication with httpOnly Cookies

You've implemented JWT authentication for your application, storing tokens in local storage because it seemed convenient. But now you're seeing alarming posts about XSS vulnerabilities and token theft, making you question if your user data is truly secure. You're looking for better options but feeling overwhelmed by conflicting advice and technical jargon.

Security shouldn't be this confusing, especially when it comes to protecting your users' sensitive information.

The Security Risks of Local Storage

When you store authentication tokens in local storage, you're essentially placing your users' session keys in a publicly accessible cabinet. Any JavaScript running on your page—including malicious scripts injected through XSS attacks—can access this storage and steal these tokens.

This isn't just theoretical. A successful XSS attack against your application could allow attackers to:

  • Capture JWT tokens and impersonate users

  • Access private user data

  • Perform actions on behalf of users

  • Maintain this access until the token expires (which could be hours or days)

You might be thinking, "But I sanitize all my inputs to prevent XSS!" While that's a good practice, complete XSS prevention is notoriously difficult. A single vulnerability anywhere in your application or in a third-party library you use could expose all your users' tokens.

Why httpOnly Cookies Are a Superior Alternative

There's a more secure approach: storing your JWT tokens in httpOnly cookies. These special cookies cannot be accessed by JavaScript, making them immune to theft through XSS attacks.

// Setting an httpOnly cookie on the server (Express.js example)
res.cookie('accessToken', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: 15 * 60 * 1000 // 15 minutes
});

When you use httpOnly cookies:

  1. The browser automatically sends the cookie with every request to your domain

  2. JavaScript cannot access the cookie's contents, protecting it from XSS attacks

  3. Your authentication flow becomes more secure with minimal changes to your frontend code

Major tech companies including Google, Microsoft, and Netflix use httpOnly cookies for token storage—and for good reason.

Understanding JWT Structure and Purpose

Before diving deeper into implementation, let's review what JWTs actually are and why they're valuable.

A JWT consists of three parts separated by dots:

header.payload.signature
  1. Header: Contains metadata about the token type and signing algorithm

    {
      "alg": "HS256",
      "typ": "JWT"
    }
    
  2. Payload: Contains the claims (data) you want to transmit

    {
      "userId": "123",
      "name": "Jane Doe",
      "role": "admin",
      "exp": 1698508583
    }
    
  3. Signature: Verifies the token hasn't been tampered with

    HMACSHA256(
      base64UrlEncode(header) + "." + base64UrlEncode(payload),
      secret
    )
    

JWTs are valuable because they're self-contained, allowing stateless authentication. The server doesn't need to query a database on every request to verify who the user is—it can simply validate the token's signature and read the claims.

Implementing JWT Authentication with httpOnly Cookies

Now that we understand the advantages of httpOnly cookies, let's implement a complete authentication flow. We'll use Express.js on the backend and React on the frontend, but the principles apply to any stack.

Server-Side Implementation

First, set up your Express server with the necessary middlewares:

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

const app = express();

// Enable parsing cookies and JSON body
app.use(cookieParser());
app.use(express.json());

// Configure CORS to allow credentials
app.use(cors({
  origin: 'https://yourfrontend.com',
  credentials: true
}));

Next, create login and authentication middleware endpoints:

// Login endpoint
app.post('/api/login', (req, res) => {
  const { username, password } = req.body;
  
  // Validate credentials (simplified for demo)
  if (username === 'user' && password === 'pass') {
    // Create JWT token
    const accessToken = jwt.sign(
      { userId: '123', role: 'user' },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    );
    
    // Create refresh token with longer expiry
    const refreshToken = jwt.sign(
      { userId: '123' },
      process.env.REFRESH_TOKEN_SECRET,
      { expiresIn: '7d' }
    );
    
    // Set cookies
    res.cookie('accessToken', accessToken, {
      httpOnly: true,
      secure: true, // Use secure in production
      sameSite: 'strict',
      maxAge: 15 * 60 * 1000 // 15 minutes
    });
    
    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      path: '/api/refresh', // Only sent to refresh endpoint
      maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
    });
    
    // Send user data (but not the tokens)
    res.json({ userId: '123', username: 'user' });
  } else {
    res.status(401).json({ message: 'Invalid credentials' });
  }
});

// Middleware to authenticate requests
const authenticateToken = (req, res, next) => {
  const token = req.cookies.accessToken;
  
  if (!token) {
    return res.status(401).json({ message: 'Authentication required' });
  }
  
  jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
    if (err) {
      return res.status(403).json({ message: 'Invalid or expired token' });
    }
    
    req.user = user;
    next();
  });
};

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

Token Refresh Implementation

To handle token expiration gracefully, implement a refresh token endpoint:

// Token refresh endpoint
app.post('/api/refresh', (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  
  if (!refreshToken) {
    return res.status(401).json({ message: 'Refresh token required' });
  }
  
  jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, user) => {
    if (err) {
      return res.status(403).json({ message: 'Invalid or expired refresh token' });
    }
    
    // Create new access token
    const accessToken = jwt.sign(
      { userId: user.userId, role: 'user' },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    );
    
    // Set new access token cookie
    res.cookie('accessToken', accessToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 15 * 60 * 1000
    });
    
    res.json({ message: 'Token refreshed successfully' });
  });
});

// Logout endpoint
app.post('/api/logout', (req, res) => {
  // Clear both cookies
  res.clearCookie('accessToken');
  res.clearCookie('refreshToken', { path: '/api/refresh' });
  
  res.json({ message: 'Logged out successfully' });
});

Client-Side Implementation

On the frontend, you'll need to configure your requests to include credentials:

// React example using fetch
const login = async (username, password) => {
  try {
    const response = await fetch('https://yourapi.com/api/login', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ username, password }),
      credentials: 'include' // Important! This tells fetch to include cookies
    });
    
    if (!response.ok) {
      throw new Error('Login failed');
    }
    
    const userData = await response.json();
    // Store user data in state management (React Context, Redux, etc.)
    return userData;
  } catch (error) {
    console.error('Login error:', error);
    throw error;
  }
};

For handling protected API requests:

// Utility function for API requests
const apiRequest = async (url, options = {}) => {
  try {
    const response = await fetch(url, {
      ...options,
      credentials: 'include',
      headers: {
        ...options.headers,
        'Content-Type': 'application/json',
      }
    });
    
    // If unauthorized, try to refresh the token
    if (response.status === 401) {
      const refreshResponse = await fetch('https://yourapi.com/api/refresh', {
        method: 'POST',
        credentials: 'include'
      });
      
      if (refreshResponse.ok) {
        // Retry the original request after token refresh
        return apiRequest(url, options);
      } else {
        // Redirect to login if refresh fails
        window.location.href = '/login';
        throw new Error('Session expired');
      }
    }
    
    if (!response.ok) {
      throw new Error('API request failed');
    }
    
    return response.json();
  } catch (error) {
    console.error('API error:', error);
    throw error;
  }
};

// Example usage
const fetchUserData = () => apiRequest('https://yourapi.com/api/protected');

Mitigating CSRF Vulnerabilities

While httpOnly cookies protect against XSS, they can be vulnerable to Cross-Site Request Forgery (CSRF) attacks. Here's how to protect against CSRF:

The SameSite attribute restricts when cookies are sent cross-origin:

res.cookie('accessToken', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict', // Only sent in same-site requests
  maxAge: 15 * 60 * 1000
});

Setting SameSite=strict ensures cookies are only sent for same-site requests, which significantly reduces CSRF risks.

2. Implement CSRF Tokens

For additional protection, especially in browsers that don't fully support SameSite, implement CSRF tokens:

// Generate and send CSRF token
app.get('/api/csrf-token', authenticateToken, (req, res) => {
  const csrfToken = crypto.randomBytes(64).toString('hex');
  
  // Store the token in a cookie that JS can read (not httpOnly)
  res.cookie('X-CSRF-TOKEN', csrfToken, {
    httpOnly: false,
    secure: true,
    sameSite: 'strict',
    maxAge: 15 * 60 * 1000
  });
  
  res.json({ csrfToken });
});

// Middleware to verify CSRF token
const verifyCsrfToken = (req, res, next) => {
  const csrfTokenFromCookie = req.cookies['X-CSRF-TOKEN'];
  const csrfTokenFromHeader = req.headers['x-csrf-token'];
  
  if (!csrfTokenFromCookie || !csrfTokenFromHeader || csrfTokenFromCookie !== csrfTokenFromHeader) {
    return res.status(403).json({ message: 'CSRF token validation failed' });
  }
  
  next();
};

// Apply CSRF protection to mutation endpoints
app.post('/api/user/update', authenticateToken, verifyCsrfToken, (req, res) => {
  // Handle user update
});

On the client side, include the CSRF token in your requests:

const updateUser = async (userData) => {
  // Get CSRF token from cookie
  const csrfToken = document.cookie
    .split('; ')
    .find(row => row.startsWith('X-CSRF-TOKEN='))
    ?.split('=')[1];
    
  return apiRequest('https://yourapi.com/api/user/update', {
    method: 'POST',
    headers: {
      'X-CSRF-TOKEN': csrfToken
    },
    body: JSON.stringify(userData)
  });
};

Common Pitfalls and Best Practices

1. Handling Token Expiration

One common challenge is dealing with token expiration during active user sessions. We already implemented a refresh mechanism, but here's how to handle it even more efficiently using an async mutex to prevent multiple simultaneous refresh attempts:

import { Mutex } from 'async-mutex';

const tokenRefreshMutex = new Mutex();

const apiRequest = async (url, options = {}) => {
  try {
    const response = await fetch(url, {
      ...options,
      credentials: 'include',
      headers: {
        ...options.headers,
        'Content-Type': 'application/json',
      }
    });
    
    if (response.status === 401) {
      // Acquire mutex to prevent multiple refresh requests
      const release = await tokenRefreshMutex.acquire();
      
      try {
        // Check if another request already refreshed the token
        const secondAttemptResponse = await fetch(url, {
          ...options,
          credentials: 'include',
          headers: {
            ...options.headers,
            'Content-Type': 'application/json',
          }
        });
        
        if (secondAttemptResponse.ok) {
          return secondAttemptResponse.json();
        }
        
        // Try to refresh the token
        const refreshResponse = await fetch('https://yourapi.com/api/refresh', {
          method: 'POST',
          credentials: 'include'
        });
        
        if (refreshResponse.ok) {
          // Retry original request with new token
          const finalResponse = await fetch(url, {
            ...options,
            credentials: 'include',
            headers: {
              ...options.headers,
              'Content-Type': 'application/json',
            }
          });
          
          if (finalResponse.ok) {
            return finalResponse.json();
          }
        }
        
        // Redirect to login if refresh fails
        window.location.href = '/login';
        throw new Error('Session expired');
      } finally {
        // Release mutex
        release();
      }
    }
    
    if (!response.ok) {
      throw new Error(`API request failed: ${response.status}`);
    }
    
    return response.json();
  } catch (error) {
    console.error('API error:', error);
    throw error;
  }
};

2. Secure Logout Implementation

Ensure your logout functionality properly invalidates both cookies:

const logout = async () => {
  try {
    await fetch('https://yourapi.com/api/logout', {
      method: 'POST',
      credentials: 'include'
    });
    
    // Clear any client-side state
    yourStateManager.clearUserData();
    
    // Redirect to login page
    window.location.href = '/login';
  } catch (error) {
    console.error('Logout error:', error);
  }
};

3. CORS Configuration

Incorrect CORS settings are a common source of frustration when implementing cookie-based auth. Ensure your server has the proper configuration:

app.use(cors({
  origin: ['https://yourfrontend.com', 'https://dev.yourfrontend.com'], // Can be an array
  credentials: true, // Critical for cookies
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'X-CSRF-TOKEN']
}));

Conclusion

Storing JWTs in httpOnly cookies provides significantly better security than local storage by protecting tokens from JavaScript-based theft. While no security measure is perfect, httpOnly cookies combined with proper CSRF protection create a robust defense against common web vulnerabilities.

By implementing the patterns described in this guide, you can:

  1. Protect user authentication tokens from XSS attacks

  2. Maintain seamless user experiences with automatic token renewal

  3. Properly handle session termination and token invalidation

  4. Defense against CSRF attacks with token validation

Remember that security is a continuous process, not a one-time implementation. Stay updated on best practices, regularly audit your authentication flows, and be prepared to adapt as new security challenges emerge.

For additional resources on JWT security, check out:

By taking these steps to secure your JWT implementation, you're demonstrating a commitment to protecting your users' data and building trust in your application.

Raymond Yeh

Raymond Yeh

Published on 27 April 2025

Get engineers' time back from marketing!

Don't let managing a blog on your site get in the way of your core product.

Wisp empowers your marketing team to create and manage content on your website without consuming more engineering hours.

Get started in few lines of codes.

Choosing a CMS
Related Posts
Understanding HttpOnly Cookies and Security Best Practices

Understanding HttpOnly Cookies and Security Best Practices

Confused about managing HttpOnly cookies across domains? Discover battle-tested patterns for implementing secure authentication while avoiding frustrating redirect issues.

Read Full Story
Best Practices in Implementing JWT in Next.js 15

Best Practices in Implementing JWT in Next.js 15

Comprehensive guide to JWT implementation in Next.js 15: Learn secure token storage, middleware protection, and Auth.js integration. Master authentication best practices today.

Read Full Story
Implementing Robust Cookie Management for Next.js Applications

Implementing Robust Cookie Management for Next.js Applications

Tired of users getting logged out unexpectedly? Learn how to fix those frustrating cookie race conditions in Next.js once and for all, with battle-tested solutions for reliable authentication flows.

Read Full Story
Loading...