AvnishYadav
WorkProjectsBlogsNewsletterSupportAbout
Work With Me

Avnish Yadav

Engineer. Automate. Build. Scale.

Ā© 2026 Avnish Yadav. All rights reserved.

The Automation Update

AI agents, automation, and micro-SaaS. Weekly.

Explore

  • Home
  • Projects
  • Blogs
  • Newsletter Archive
  • About
  • Contact
  • Support

Legal

  • Privacy Policy

Connect

LinkedInGitHubInstagramYouTube
Secure Frontend Auth: The Definitive Guide to JWTs, Cookies, and Storage Strategies
2026-02-21

Secure Frontend Auth: The Definitive Guide to JWTs, Cookies, and Storage Strategies

7 min readTutorialsDevelopmentSecurityWeb DevelopmentNode.jsAuthenticationCybersecurityReactJWT

A builder's guide to implementing secure JWT authentication. Learn why LocalStorage is dangerous, how HttpOnly cookies work, and how to build a silent refresh flow with Axios.

The "Where Do I Put This?" Dilemma

If you have built a modern single-page application (SPA), you have faced the authentication standoff. You receive a JSON Web Token (JWT) from your backend. Now, where do you put it?

Most tutorials—and unfortunately, many production apps—take the path of least resistance: localStorage.setItem('token', jwt). It works. It persists across reloads. It is easy to implement.

It is also a security vulnerability waiting to be exploited.

As engineers building intelligent agents and automation systems, we handle sensitive user data. We cannot afford lazy security practices. In this deep dive, I am going to walk you through the architecture of a secure frontend authentication system, dissect the storage debate, and show you the code to implement a robust HttpOnly cookie strategy.


Understanding the Threat Model: XSS vs. CSRF

To make an informed decision on storage, you need to understand the two main vectors of attack against frontend sessions.

1. Cross-Site Scripting (XSS)

XSS occurs when an attacker manages to inject malicious JavaScript into your application. This could come from a compromised npm package, a malicious advertisement script, or unsanitized user input.

The Risk: If your JWT is stored in localStorage or sessionStorage, any JavaScript running on your page can read it. An attacker just needs to run localStorage.getItem('token') and send it to their own server. Game over.

2. Cross-Site Request Forgery (CSRF)

CSRF happens when a malicious site tricks a user's browser into sending a request to your API. Since cookies are automatically sent with every request to the domain they belong to, the API might process the request thinking it is legitimate.

The Trade-off:

  • LocalStorage is vulnerable to XSS but immune to CSRF (because the token is not sent automatically; you have to manually attach it).
  • Cookies are vulnerable to CSRF but can be made immune to XSS (using the HttpOnly flag).

My Verdict: It is significantly easier to mitigate CSRF (using SameSite attributes and anti-CSRF tokens) than it is to perfectly prevent XSS in a complex modern JavaScript application. Therefore, HttpOnly cookies are the superior choice for storage.


The Architecture: The Silent Refresh Pattern

We are going to build an authentication flow that balances security with user experience (UX). We don't want the user to log in every 15 minutes, but we don't want a permanent access key floating around.

The Strategy

  1. Access Token (Short-lived): Valid for 15 minutes. Used to authorize API requests.
  2. Refresh Token (Long-lived): Valid for 7 days. Used to get a new Access Token.
  3. Storage: The Refresh Token is stored in a strict HttpOnly cookie. The Access Token is stored in memory (JavaScript variable) or a separate non-HttpOnly cookie if absolutely necessary (but memory is safer).

This is often called the "Silent Refresh" approach.


Step 1: The Backend Setup (Node/Express)

Before touching the React/Vue/Next.js frontend, your backend must be configured to set cookies correctly. You cannot set an HttpOnly cookie from frontend JavaScript; the server must send it in the response header.

// sending the token upon login
res.cookie('refreshToken', refreshToken, {
  httpOnly: true, // Prevents client JS from reading the cookie
  secure: process.env.NODE_ENV === 'production', // HTTPS only
  sameSite: 'strict', // CSRF protection
  maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});

res.json({ accessToken }); // Send access token in body

Note on SameSite: Setting this to strict provides excellent CSRF protection, but if your API lives on a different subdomain than your frontend (e.g., api.app.com vs app.com), you may need to use lax or configure CORS credentials carefully.


Step 2: Frontend Implementation with Axios

On the frontend, the complexity lies in handling the token expiration gracefully. If an API call fails with a 401 Unauthorized error, we want to attempt to refresh the token and retry the request without the user noticing.

We will use Axios Interceptors for this.

Setting up the Instance

First, create a dedicated Axios instance. Do not use the global instance; it makes testing and isolation difficult.

import axios from 'axios';

const api = axios.create({
  baseURL: process.env.REACT_APP_API_URL,
  withCredentials: true, // Crucial! Allows sending cookies with requests
  headers: {
    'Content-Type': 'application/json',
  },
});

export default api;

The Request Interceptor

This attaches the Access Token (which we are keeping in memory/state) to every request.

import { getAccessToken } from './authService'; // Simple getter for your memory variable

api.interceptors.request.use(
  (config) => {
    const token = getAccessToken();
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

The Response Interceptor (Where the Magic Happens)

This is the critical part of the build. We need to catch 401 errors, pause the queue of requests, refresh the token, and then retry the original failed request.

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    // Prevent infinite loops if the refresh endpoint itself fails
    if (error.response.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        // Call the refresh endpoint
        // The browser automatically sends the HttpOnly cookie with this request
        const rs = await axios.post(`${process.env.REACT_APP_API_URL}/auth/refresh`, {}, {
             withCredentials: true 
        });

        const { accessToken } = rs.data;
        
        // Update the token in your state/memory
        setAccessToken(accessToken);

        // Update the header for the failed request
        originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;

        // Retry the original request
        return api(originalRequest);
      } catch (_error) {
        // Refresh token is invalid/expired. Logout the user.
        window.location.href = '/login';
        return Promise.reject(_error);
      }
    }

    return Promise.reject(error);
  }
);

Managing State in React

Since we are storing the Access Token in memory, a page refresh will wipe it out. This is a common point of confusion. How do we stay logged in?

When your app initializes (e.g., inside App.js or a Context Provider), you must run an initial check.

const AuthProvider = ({ children }) => {
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const persistLogin = async () => {
      try {
        // Attempt to get a new access token using the HttpOnly cookie
        // that persisted across the reload.
        await refreshAccessToken(); 
      } catch (err) {
        console.log("User is not authenticated");
      } finally {
        setLoading(false);
      }
    };

    persistLogin();
  }, []);

  if (loading) return ;

  return {children};
};

With this flow, the user never feels disconnected. Even though the memory variable is lost on refresh, the browser holds the HttpOnly cookie. The app loads, immediately hits the /refresh endpoint, gets a new access token, and the user continues seamlessly.


Common Pitfalls to Avoid

1. Storing Sensitive Data in the JWT

A JWT is just base64 encoded strings. Anyone can decode it. Never put passwords, PII (Personally Identifiable Information), or sensitive API keys inside the JWT payload. Stick to userId, role, and exp (expiration).

2. Ignoring CSRF because "I use React"

If you use cookies, you must mitigate CSRF. While the SameSite attribute handles 90% of modern cases, consider implementing a "Double Submit Cookie" pattern or using a CSRF token header if you are dealing with high-security financial or medical data.

3. The "Localhost" Trap

Cookies behave differently on localhost vs. production domains, specifically regarding the Secure flag. Ensure your backend logic checks process.env.NODE_ENV so you aren't trying to force Secure cookies over HTTP during development.


Conclusion: Build for Resilience

Implementing authentication correctly takes more time than just dumping a token into local storage. But the result is a system that is resilient against script injection attacks and provides a smooth user experience.

By decoupling your refresh token (HttpOnly cookie) from your access token (memory), you create a barrier that significantly raises the difficulty for attackers. As we build more autonomous agents and tools, securing the gateway to these systems is not optional—it's the foundation.

Share

Comments

Loading comments...

Add a comment

By posting a comment, you’ll be subscribed to the newsletter. You can unsubscribe anytime.

0/2000