v0.14 is out: a mobile auth pack for Swift/iOS and Android, catching insecure token storage, cleartext traffic, and OAuth in embedded WebViews. Read more →
MEDIUM AI PREVALENCE: HIGH auth.oauth.token-in-localstorage

An OAuth/OIDC token is written to localStorage / sessionStorage, readable by any script on the origin.

Why AI tools produce this: AI coding tools generate this anti-pattern by default, it appears in a large share of AI-written auth code.

Why this matters

The key is token-named (access_token, refresh_token, id_token, …), and web storage is exposed to every script on the page, so any XSS, including a compromised third-party dependency, can exfiltrate the token via getItem(...). There is no browser-side mitigation, unlike HttpOnly cookies.

Keep access/refresh/id tokens in memory only, or have the server issue them in a Secure; HttpOnly; SameSite=Strict cookie. sessionStorage is no safer than localStorage against XSS: it grants the same attacker capability.

See CWE-922: Insecure Storage of Sensitive Information.

VULNERABLE
vulnerable.ts
declare const localStorage: Storage;
declare const sessionStorage: Storage;

export function storeAccess(token: string) {
  // ruleid: auth.oauth.token-in-localstorage
  localStorage.setItem('access_token', token);
}

export function storeRefresh(rt: string) {
  // ruleid: auth.oauth.token-in-localstorage -- sessionStorage is no safer against XSS
  sessionStorage.setItem('refresh_token', rt);
}

export function storeId(idToken: string) {
  // ruleid: auth.oauth.token-in-localstorage
  localStorage.setItem('id_token', idToken);
}

export function storeAccessCamel(token: string) {
  // ruleid: auth.oauth.token-in-localstorage -- camelCase accessToken key
  localStorage.setItem('accessToken', token);
}

export function storeRefreshCamel(rt: string) {
  // ruleid: auth.oauth.token-in-localstorage
  sessionStorage.setItem('refreshToken', rt);
}

export function storeIdProp(t: string) {
  // ruleid: auth.oauth.token-in-localstorage -- property-form write
  localStorage.idToken = t;
}

export function storeAccessBracket(t: string) {
  // ruleid: auth.oauth.token-in-localstorage -- bracket-form write
  localStorage['access_token'] = t;
}

export function storeViaWindow(token: string) {
  // ruleid: auth.oauth.token-in-localstorage
  window.localStorage.setItem('id_token', token);
}
SAFE
safe.ts
declare const localStorage: Storage;
declare const sessionStorage: Storage;

// ok: auth.oauth.token-in-localstorage -- non-token key, must not match
export function storeTheme() {
  localStorage.setItem('theme', 'dark');
}

// ok: auth.oauth.token-in-localstorage -- arbitrary UI prefs are not tokens
export function storeUiPrefs(name: string) {
  localStorage.setItem('username', name);
  localStorage.setItem('sidebar_collapsed', '1');
  sessionStorage.setItem('last_route', '/dashboard');
}

// ok: auth.oauth.token-in-localstorage -- token kept in memory, never persisted
let inMemoryAccessToken: string | null = null;
export function setAccessToken(t: string) {
  inMemoryAccessToken = t;
}
export function getAccessToken() {
  return inMemoryAccessToken;
}

// ok: auth.oauth.token-in-localstorage -- server sets a Secure HttpOnly cookie
export function buildAuthCookie(token: string): string {
  return `session=${token}; Secure; HttpOnly; SameSite=Strict`;
}

Suppressing this rule

If a finding is a genuine false positive, scope the suppression to the exact line and leave a reason, never disable the rule project-wide. Disable directives are line-scoped by design.

// oauthlint-disable-next-line auth.oauth.token-in-localstorage -- <reason>

References

https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html#local-storage ↗https://cwe.mitre.org/data/definitions/922.html ↗