squirrelscan
GitHub

Auth tokens in localStorage: why a stored JWT is exposed

A JWT or session token in localStorage or sessionStorage is readable by any script on the page. squirrel finds the writes in your scripts and names the key.

What a token in web storage is

localStorage and sessionStorage are readable by every script that runs on the page. That includes your own bundle, every third-party tag and any code an attacker injects through a cross-site scripting (XSS) bug. A JWT, a session token or an access token kept there is one XSS bug away from an account takeover, and the attacker can use it from their own machine.

An httpOnly cookie is not readable from script, so the same bug cannot copy it. That is why the safer pattern is a cookie the server sets with HttpOnly, Secure and SameSite.

What squirrel checks

The rule is passive. It reads the inline <script> blocks of each crawled page and the external JavaScript files the crawl already fetched. It makes no extra request. Each page reports the inline writes in its own scripts and the writes in every external script it loads. A shared script gives the same check on each page that loads it, so the report groups those into one issue. The rule emits checks named token-storage:

  • Skipped when there is no parsed document. Message: No document available.
  • Pass when no write was found. Message: No auth tokens written to localStorage or sessionStorage.
  • Info, as a separate check named token-storage-truncated, when a script that mentions web storage is longer than 6 MB and was read only up to that length. Message: Script https://example.com/static/big.js is longer than 6 MB: only the first 6 MB were scanned for token writes. It never counts as a finding, so a page with only this note still passes.
  • Warn when a write was found, one check for the page’s inline scripts and one for each external script. Message: 1 auth token write(s) to web storage: localStorage["access_token"] in https://example.com/static/app.js. Each item names the script, the storage, the key and the reason.

Two things count as a token write:

ReasonMatches
key-nameA write whose literal key names a credential: token, jwt, bearer, access_token, idToken, refreshToken, authToken, or auth and session on their own or beside id, key, data, user and similar (session_id, authUser).
jwt-valueA write whose literal value has the shape of a JWT (eyJ... header, eyJ... payload and a signature), under any key.

These write forms are read: localStorage.setItem("k", v), sessionStorage.setItem("k", v), localStorage["k"] = v and localStorage.k = v, with an optional window. prefix. A key that is computed at runtime is only judged by its value.

What is not flagged

Keys are split into words at punctuation and camelCase, and whole words decide, not substrings. These stay clean:

  • UI and app state: theme, cart, lang, ui_state, session_theme, auth_banner_dismissed, cookie_consent.
  • Other kinds of token: csrf_token, push_token, next_page_token.
  • Timestamps and counters: token_expires_at, session_start_time.
  • Words that only contain a keyword, such as authority and tokenizer.
  • A write that clears a key, such as setItem("token", "") or setItem("token", null).
  • Reads, removals and comparisons, such as getItem("token") and localStorage.token == null.
  • Script blocks that are not JavaScript, such as type="application/json".

When a key has a namespace such as app:auth or app.session, only the last part is judged.

The word lists are a blocklist that wins over a strong word, so a key that mixes them is missed: token_view, jwt_mode and auth_token_time stay clean. That trades some recall for precision. A bare auth or session key is flagged for any value that is not empty or a flag, so setItem("auth", "guest") warns.

Third-party scripts

A script served from another registrable domain than the page, such as an analytics tag, a widget or an auth SDK, is labelled third-party script in the message and the item, and carries thirdParty in the item meta. The status does not change. A vendor script that writes its own session_id is not something you can fix, so read the label first. An auth SDK that stores your user’s JWT in web storage is still your risk, because you chose how it is configured.

The value is never printed

A token in a report is a token that leaks again. The report names the script and the key, and nothing else about the value. A key that is itself JWT-shaped is dropped from the report.

Limits

The rule reads script text, so it sees what a script writes and not what the browser holds. A script with no captured content (the fetch failed or it was too large) is not scanned. A script longer than 6 MB that mentions localStorage or sessionStorage is read only up to that length, and the report says so (see the token-storage-truncated check above), so a pass on it is not a full scan. A token written through a variable key, a wrapper library or a minified alias is not found unless its value is a literal JWT. A finding is a prompt to check where the token goes, not proof that a live token is exposed.

How to fix it

Move the credential into an httpOnly cookie that the server sets, and let the browser send it. If a token has to stay in script, hold it in memory for the life of the page, give it a short lifetime and rotate refresh tokens. Add a strict Content-Security-Policy to limit what can run on the page.

If the key is not a credential, rename it so it says what it holds, for example theme in place of session.

Rule IDsecurity/token-storage
CategorySecurity
ScopePer-page
Severitywarning
Weight6/10

Enable / disable

Disable this rule

[rules]
disable = ["security/token-storage"]

Disable all Security rules

[rules]
disable = ["security/*"]

Enable only this rule

[rules]
enable = ["security/token-storage"]

enable replaces the default ["*"], so this is the only rule selected. Leave disable out: a rule that matches any disable pattern is skipped even when enable names it. Only an enabled setting under a rule’s own [rule_options] table overrides both lists.

Type to search…

↑↓ navigate↵ openesc close