GDPR Art. 32 requires appropriate security measures for personal data, and storing PII in localStorage is a direct failure of that obligation: any JavaScript running on the page — including third-party analytics, ad scripts, or injected malicious code — can read localStorage in full. OWASP A02 (Cryptographic Failures) explicitly calls out sensitive data exposure via client-side storage as a top vulnerability. CWE-522 covers insufficiently protected credentials, which includes JWTs containing PII stored where XSS attacks can exfiltrate them. ISO-27001:2022 A.8.24 requires cryptographic protection of data at rest — but localStorage provides no encryption at all. If a user's email, name, or session token is in localStorage, a single XSS payload harvests it silently.
Medium because exploitation requires a co-located XSS or malicious script, but that is a realistic threat given the volume of third-party analytics and tracking scripts most apps load.
Move authentication tokens from localStorage to HttpOnly cookies set server-side. HttpOnly cookies are inaccessible to JavaScript by specification, eliminating the XSS exfiltration vector.
// app/api/auth/login/route.ts (Next.js)
import { cookies } from 'next/headers'
export async function POST(req: Request) {
const { email, password } = await req.json()
const token = await authenticateUser(email, password)
cookies().set('session', token, {
httpOnly: true, // inaccessible to JavaScript
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 60 * 60 * 24 * 7,
path: '/',
})
return Response.json({ ok: true })
}
Audit all localStorage.setItem calls in the codebase. For each one, verify no PII (email, phone, full name, address) is stored. User preferences (theme, language) are safe to store in localStorage — contact information is not.
ID: data-protection.storage-retention.browser-storage-secure
Severity: medium
What to look for: Enumerate every relevant item. Search the codebase for all calls to localStorage.setItem and sessionStorage.setItem. For each call, inspect what data is stored. Common violations: storing the full user object (which includes email, name, phone), storing JWT tokens that contain PII in their payload, storing form state that includes personal data. Also check IndexedDB usage. Verify that authentication tokens are handled via HttpOnly cookies (set server-side via Set-Cookie) rather than localStorage. Check if any browser-extension or third-party library is storing PII in localStorage on the application's behalf.
Pass criteria: At least 1 of the following conditions is met. No personal data (email, phone, address, SSN, full name) is stored in plaintext in localStorage or sessionStorage. Authentication tokens use HttpOnly, Secure, SameSite cookies. If any data must be in browser storage (e.g., user preferences that include username), it is either non-sensitive or encrypted.
Fail criteria: User email, phone number, full name, or other PII stored in plaintext in localStorage or sessionStorage. JWT tokens that include PII stored in localStorage (accessible to any JavaScript, including third-party scripts).
Skip (N/A) when: Application does not use browser storage at all and uses only server-side sessions.
Detail on fail: Example: "localStorage stores the full user object including email and phone at key 'current_user'. Accessible to all JavaScript on the page." or "JWT containing user email and name stored in localStorage.getItem('token') rather than HttpOnly cookie.".
Remediation: Move authentication tokens to HttpOnly cookies and remove PII from browser storage:
// BEFORE — wrong: JWT in localStorage
// localStorage.setItem('token', jwtToken) // DO NOT DO THIS
// AFTER — correct: HttpOnly cookie set server-side (Next.js API route example)
// app/api/auth/login/route.ts
import { cookies } from 'next/headers'
export async function POST(req: Request) {
const { email, password } = await req.json()
const token = await authenticateUser(email, password)
cookies().set('session', token, {
httpOnly: true, // not accessible to JavaScript
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
})
return Response.json({ ok: true })
}
For user preferences that need to persist client-side (theme, language), store only non-PII values. If you need to cache the user's display name for UI purposes, prefer sessionStorage over localStorage (clears on tab close) and store only non-sensitive display data, not contact information.