Storing JWTs in the Browser
Where you store a JWT in the browser is a security decision with real trade-offs. Understanding those trade-offs helps you make an informed choice.
Option 1: localStorage
Section titled “Option 1: localStorage”localStorage.setItem('token', jwt)const token = localStorage.getItem('token')localStorage.removeItem('token')Pros: Persistent across page reloads, simple API, survives browser close/reopen.
Cons: Accessible by any JavaScript on the page — vulnerable to XSS (Cross-Site Scripting) attacks. If a malicious script runs on your page (via a compromised dependency, ad injection, or DOM-based XSS), it can steal the token.
Option 2: Memory (React state)
Section titled “Option 2: Memory (React state)”// Store in React state or Contextconst [token, setToken] = useState<string | null>(null)Pros: Not accessible to any script outside your bundle — immune to XSS.
Cons: Lost on page reload — user is logged out whenever they refresh. Fine for some apps, annoying for others.
Option 3: httpOnly cookies
Section titled “Option 3: httpOnly cookies”The most secure option:
- The server sets the JWT in an
HttpOnlycookie - Browsers send the cookie automatically with every request to the same domain
- JavaScript cannot read
HttpOnlycookies — immune to XSS
Cons: Requires CORS credential configuration. Harder to set up for cross-domain APIs (your API on Railway, frontend on GitHub Pages). Requires CSRF protection.
What Bulletin uses
Section titled “What Bulletin uses”Bulletin stores the token in localStorage — and acknowledges the trade-off clearly to students.
For a learning project, localStorage is fine:
- Bulletin’s codebase doesn’t use any third-party scripts that could execute XSS
- The users and data aren’t sensitive
- The simplicity outweighs the security cost for a tutorial context
For a real production app handling sensitive data, httpOnly cookies are the right choice. The React Native, Electron, or same-domain setup makes them practical.
Token storage constants
Section titled “Token storage constants”export const TOKEN_KEY = 'bulletin_token'export const USER_KEY = 'bulletin_user'Using constants prevents typos when reading/writing storage across the app.
What gets stored
Section titled “What gets stored”Store the minimum needed:
// On login/register success:localStorage.setItem(TOKEN_KEY, token)localStorage.setItem(USER_KEY, JSON.stringify({ userId, username }))The JWT has a userId and username in its payload — but verifying the JWT client-side is pointless (the server verifies it). Store a simple user object separately for quick access in the UI.
Exercise
Section titled “Exercise”Think through the security model:
- Write a scenario where localStorage token storage leads to a security breach.
- Why doesn’t storing in React state (memory) have this problem?
- Why is httpOnly cookie storage immune to XSS?
- For Bulletin, is localStorage acceptable? What would need to change to make it production-grade?
- localStorage: persistent but accessible to JavaScript — XSS risk.
- Memory (React state): secure but lost on reload.
- httpOnly cookies: most secure (JavaScript can’t read), but complex for cross-domain APIs.
- Bulletin uses localStorage for simplicity — acceptable for learning, document the trade-off in production.