JWT Storage: Cookies vs LocalStorage
Where should you store JWT tokens? This guide covers both approaches with working code examples to help you make an informed decision.
What You'll Learn
- Implement JWT authentication using HTTP-only cookies and localStorage
- Understand XSS and CSRF attack implications
- Choose the right storage method for your application
- Apply defense-in-depth security strategies
The Two Storage Options
HTTP-Only Cookies
Cookies with the httpOnly flag cannot be accessed by JavaScript, providing protection against XSS token theft.
Key characteristics:
- Automatic: Browser sends tokens with requests
- JavaScript-proof: Cannot be stolen via XSS
- Configurable:
Secure and SameSite flags for added security
- Vulnerable to CSRF without proper protection
LocalStorage
Tokens stored in localStorage require manual attachment to requests via JavaScript.
Key characteristics:
- Manual control: You decide when to send tokens
- JavaScript-accessible: Easy to use but vulnerable to XSS
- CSRF immune: Tokens aren't sent automatically
- Developer-managed: You handle storage and cleanup
The Core Trade-off
Cookies are safer from XSS but vulnerable to CSRF. LocalStorage is immune to CSRF but exposed to XSS.
Implementation: Cookies Approach
Backend (Node.js/Express)
const express = require('express');
const jwt = require('jsonwebtoken');
const cookieParser = require('cookie-parser');
const app = express();
app.use(cookieParser());
app.use(express.json());
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
// Login endpoint
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await validateUser(email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{ userId: user.id, email: user.email },
JWT_SECRET,
{ expiresIn: '15m' }
);
res.cookie('access_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 15 * 60 * 1000
});
res.json({ success: true, user: { id: user.id, email: user.email } });
});
// Protected endpoint
app.get('/api/user/profile', (req, res) => {
const token = req.cookies['access_token'];
if (!token) {
return res.status(401).json({ error: 'Not authenticated' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
res.json({ user: decoded });
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
});
Frontend (React)
async function handleLogin(email, password) {
const response = await fetch('http://localhost:3000/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Critical for cookies
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (response.ok) {
window.location.href = '/dashboard';
}
}
// Making authenticated requests
fetch('/api/user/profile', {
credentials: 'include' // Browser automatically includes cookie
});
Implementation: LocalStorage Approach
Backend
const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();
app.use(express.json());
// Login endpoint - returns token in response body
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await validateUser(email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{ userId: user.id, email: user.email },
JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({
success: true,
token: token,
user: { id: user.id, email: user.email }
});
});
// Protected endpoint - expects Authorization header
app.get('/api/user/profile', (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Not authenticated' });
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, JWT_SECRET);
res.json({ user: decoded });
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
});
Frontend
async function handleLogin(email, password) {
const response = await fetch('http://localhost:3000/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (response.ok) {
localStorage.setItem('access_token', data.token);
window.location.href = '/dashboard';
}
}
// Making authenticated requests
const token = localStorage.getItem('access_token');
fetch('/api/user/profile', {
headers: {
'Authorization': `Bearer ${token}`
}
});
Security Deep Dive
XSS Attacks
LocalStorage: If XSS occurs, attackers can trivially steal tokens with localStorage.getItem('access_token') and use them from anywhere.
HTTP-Only Cookies: JavaScript cannot read the token. Attackers can make requests during XSS but cannot steal the token for later use.
CSRF Attacks
Cookies: Browsers automatically send cookies with requests, even from malicious sites. Use SameSite cookies and CSRF tokens for protection.
LocalStorage: Immune to CSRF because tokens must be manually attached via JavaScript, which cannot access cross-origin localStorage.
Making the Decision
Choose HTTP-Only Cookies if:
- Building financial, healthcare, or high-security applications
- Frontend and backend share a domain
- Using server-side rendering (Next.js, Remix)
- Security is top priority
Choose LocalStorage if:
- Building a modern SPA (React, Vue, Angular)
- Cross-origin setup with CORS complexity
- Building mobile or hybrid apps
- Using GraphQL or custom protocols
- Have strong XSS prevention (CSP, security audits)
Hybrid Approach (Best Security)
Store short-lived access tokens in memory (5-15 min) and long-lived refresh tokens in HTTP-only cookies. This combines the benefits of both approaches.
Best Practices
Universal (Do These Always)
- Always use HTTPS in production
- Keep tokens short-lived (15-30 minutes)
- Implement token rotation
- Build proper logout (clear client + invalidate server)
- Log authentication events and monitor patterns
XSS Prevention
- Implement Content Security Policy (CSP)
- Sanitize user input with libraries like DOMPurify
- Avoid dangerous DOM manipulation (
innerHTML, eval)
- Keep dependencies updated (
npm audit)
CSRF Prevention (For Cookies)
- Use
SameSite cookie attribute ('strict' or 'lax')
- Implement CSRF tokens for sensitive operations
- Validate request origins
Troubleshooting
Cookies not sent cross-origin
Backend: Set CORS with credentials: true. Frontend: Use credentials: 'include' in fetch.
SameSite=None not working
Requires Secure flag (HTTPS only).
Tokens not syncing across tabs (localStorage)
Listen to storage events to detect changes in other tabs.
Conclusion
HTTP-only cookies protect against XSS token theft but require CSRF protection. LocalStorage is immune to CSRF but vulnerable to XSS. The hybrid approach offers the best security.
For high-security apps, use cookies or hybrid. For modern SPAs with strong XSS prevention, localStorage is acceptable. Remember: security is a process, not a checkbox.
For a more detailed version of this guide with additional examples, troubleshooting scenarios, and in-depth explanations, visit: https://stackinsight.dev/blog/jwt-storage-cookies-vs-localstorage-which-is-right-for-your-app/