JWT Storage: Cookies vs LocalStorage – Which Is Right for Your App?

JWT Storage: Cookies vs LocalStorage – Which Is Right for Your App?

posted Originally published at stackinsight.dev 5 min read

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/

3 Comments

1 vote
2
1

More Posts

Your Tech Stack Isn’t Your Ceiling. Your Story Is

Karol Modelskiverified - Apr 9

Your Backup Data Knows More Than You Think. HYCU aiR Is Finally Asking It the Right Questions.

Tom Smithverified - May 14

Your App Feels Smart, So Why Do Users Still Leave?

kajolshah - Feb 2

Your AI Doesn't Just Write Tests. It Runs Them Too.

Kevin Martinez - May 12

Optimizing the Clinical Interface: Data Management for Efficient Medical Outcomes

Huifer - Jan 26
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

4 comments
4 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!