Why JavaScript needs its own care

JavaScript in a web page runs inside the user's browser, with the user's session: it can read what is on the screen, send requests with the user's cookies and change what the user sees. If an attacker manages to get their script into your page, it runs with exactly the same power as yours. That is what cross-site scripting (XSS) is, and most of this page is about not letting that happen.

The opposite direction matters just as much: everything you send to the browser is in the hands of the user. They can read every line of your script, change variables in the developer tools and send any request they like without your page. This page covers the JavaScript side; the general web attacks (injection, CSRF, broken access control) are explained in web application security and web and API security.

Never trust the client

Validation in the browser is a convenience for honest users: it gives quick feedback on a form. It is not a security control, because the user controls the browser. A price, a discount, a role or a "this field is required" check performed in JavaScript must be checked again on the server (see input validation). The same applies to hiding a button: if the server accepts the request behind it, anyone can send that request.

DOM-based cross-site scripting

DOM-based XSS is cross-site scripting that happens entirely in the browser: your own script takes data an attacker can influence (a source) and passes it to a function or property that interprets it as HTML or code (a sink). The server may never see the malicious data at all, for example when it travels in the part of the URL after #.

Sources and sinks. Untrusted input, such as location.hash, URL parameters, postMessage data, user content from an API and web storage, flows into your code. Passed to a sink that parses HTML or code, such as innerHTML, outerHTML, document.write, eval, or a link's href set to an unchecked URL, it is an XSS risk. Passed to textContent, to createElement with setAttribute, or as a URL checked against an allowlist, it is treated as plain text and is safe. Untrusted input location.hash URLSearchParams postMessage data user content from API localStorage Parsed as HTML or code innerHTML, outerHTML document.write(), eval() a.href = unchecked URL Treated as plain text textContent createElement() + setAttribute() URL checked against allowlist XSS risk safe
The same untrusted value is harmless or dangerous depending on where your code puts it. Avoid the sinks at the top; when you cannot, sanitize first.

Safe DOM methods

The most common sink is the innerHTML property. It parses its value as HTML, so markup in user data becomes real elements, including elements with event handler attributes that run script. textContent inserts the value as text: a < stays a visible < character and nothing is parsed. This code runs in a browser:

// A comment typed by a customer, shown on the outage page
const comment = ticket.customerComment;

// Unsafe: the comment is parsed as HTML
commentBox.innerHTML = comment;

// Safe: the comment is inserted as plain text
commentBox.textContent = comment;

// Safe: build elements and set text and attributes separately
const item = document.createElement("li");
item.textContent = ticket.title;
item.dataset.ticketId = ticket.id;
ticketList.append(item);

Other sinks to treat with the same suspicion are outerHTML, insertAdjacentHTML() and document.write(). Data does not have to come from a form to be untrusted: URL parameters, the fragment after #, messages from other windows, web storage and anything an API returns that users once typed are all sources.

Checking URLs

Setting href or src with setAttribute does not parse HTML, but a URL has its own danger: a javascript: URL runs script when the link is clicked. Check the scheme against an allowlist, and let the URL class do the parsing. Simple string checks such as startsWith("javascript:") miss upper case and leading spaces, which browsers accept:

function safeLinkUrl(input) {
  let url;
  try {
    url = new URL(input, "https://portal.example.com/");
  } catch {
    return null;
  }
  return url.protocol === "https:" || url.protocol === "http:" ? url.href : null;
}

const inputs = [
  "https://status.example.com/outages",
  "/tickets/INC-31902",
  "javascript:void(0)",
  "  JavaScript:void(0)",
  "data:text/html,hello",
  "https://",
];
for (const input of inputs) {
  console.log(JSON.stringify(input), "->", safeLinkUrl(input));
}
"https://status.example.com/outages" -> https://status.example.com/outages
"/tickets/INC-31902" -> https://portal.example.com/tickets/INC-31902
"javascript:void(0)" -> null
"  JavaScript:void(0)" -> null
"data:text/html,hello" -> null
"https://" -> null

Relative links are resolved against the site's own address, and everything that is not plain https: or http: is refused. An allowlist fails closed: a scheme you did not think of is rejected, not accepted.

When you really need HTML

Sometimes users may format text, for example in a knowledge article. Then the HTML must be sanitized before it reaches innerHTML: a sanitizer parses it and keeps only an allowlist of harmless elements and attributes. Use a maintained library such as DOMPurify; do not write your own with regular expressions, because HTML parsing has too many corner cases.

Frameworks such as React, Vue and Angular escape values by default when they render them, which removes most XSS risk. Each has an explicit escape hatch for raw HTML (React's dangerouslySetInnerHTML, Vue's v-html); treat every use of one as a sink.

Escaping when you build HTML as text

Code that builds HTML as a string, such as a Node.js server or a report generator, must escape the five characters that HTML treats specially. This is the output encoding that template engines do for you:

function escapeHtml(text) {
  return String(text)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

const comment = 'Outage at <b>"Den Haag"</b> & Delft';
console.log(`<p class="comment">${escapeHtml(comment)}</p>`);
<p class="comment">Outage at &lt;b&gt;&quot;Den Haag&quot;&lt;/b&gt; &amp; Delft</p>

The browser shows the comment exactly as typed, tags and quotes included, but none of it becomes markup. This escaping is right for text between tags and in quoted attribute values; it is not enough inside a <script> block, a URL or a style, which each need their own rules. That is one more reason to prefer a template engine or the DOM methods above.

CSP and Trusted Types

Safe coding is the first defense; the browser can add a second. A Content Security Policy (CSP) tells the browser which scripts it may run, so an injected inline script is blocked even if it gets into the page. A strict policy also blocks eval() unless it explicitly allows 'unsafe-eval'.

Trusted Types is a browser API that locks the dangerous sinks. With the CSP directive require-trusted-types-for 'script', assigning a plain string to innerHTML (and to similar sinks) throws an error; the browser only accepts special objects created by a policy you define, typically one that sanitizes. All HTML insertion then goes through a few policies that can be reviewed, instead of being spread across the code. As of 2026 Trusted Types is supported in all major browsers (Chrome and Edge 83, Firefox 148, Safari 26), and has been Baseline since February 2026.

Content-Security-Policy: script-src 'self'; require-trusted-types-for 'script'
// In the browser, with the header above
commentBox.innerHTML = comment; // TypeError: a plain string is refused

const policy = trustedTypes.createPolicy("comments", {
  createHTML: (input) => DOMPurify.sanitize(input),
});
commentBox.innerHTML = policy.createHTML(comment); // accepted

eval and other code from strings

eval() is a global function that takes a string and runs it as JavaScript code, with access to the variables around it. If any part of that string comes from a user, a URL, a file or an API, whoever controls that input can run any code they want in your page or on your server. The same holds for new Function("…"), and for setTimeout and setInterval when they get a string instead of a function.

There is almost always a better tool. The most common misuse, turning text into data, is what JSON.parse() is for: it only accepts data and refuses anything that looks like code.

const settingsText = '{"region": "Den Haag", "intervalMinutes": 15}';
const settings = JSON.parse(settingsText);
console.log(settings);

try {
  JSON.parse("Math.max(15, 60)");
} catch (error) {
  console.log(`${error.name}: ${error.message}`);
}
{ region: 'Den Haag', intervalMinutes: 15 }
SyntaxError: Unexpected token 'M', "Math.max(15, 60)" is not valid JSON

To pick a function by name, look it up in an object or a Map of allowed functions instead of building code; to delay work, pass a function to setTimeout. In Node.js, the node:vm module is not a way out either: its own documentation says it is not a security mechanism, so do not use it to run untrusted code.

Prototype pollution

Every ordinary object inherits from Object.prototype through the prototype chain. Prototype pollution is a vulnerability in which attacker-controlled keys, typically __proto__ or constructor.prototype, make code write properties onto Object.prototype. From then on, every object in the program appears to have those properties. It usually happens in code that merges, clones or sets nested values from parsed JSON, such as user preferences or query parameters.

Here is a naive deep merge, of the kind many projects contain. It merges a user's saved preferences into the defaults:

function merge(target, source) {
  for (const key of Object.keys(source)) {
    const value = source[key];
    if (typeof value === "object" && value !== null) {
      if (typeof target[key] !== "object" || target[key] === null) {
        target[key] = {};
      }
      merge(target[key], value);
    } else {
      target[key] = value;
    }
  }
  return target;
}

const body = '{"theme": "dark", "__proto__": {"isAdmin": true}}';
const preferences = merge({ theme: "light" }, JSON.parse(body));
console.log(preferences.theme);

const otherUser = { name: "Amara Okafor" };
console.log(otherUser.isAdmin);
console.log(Object.hasOwn(otherUser, "isAdmin"));
dark
true
false

JSON.parse() creates an ordinary key named __proto__. But when the merge reads target["__proto__"], it gets Object.prototype itself, and copies isAdmin onto it. A completely unrelated object now reports isAdmin: true, though it does not own that property. If any code checks if (user.isAdmin), the damage is obvious; in other cases polluted properties change options of libraries or lead to crashes.

Preventing it

  • In merge, clone and "set by path" code, skip the keys __proto__, constructor and prototype, and only follow a key into a nested object when the target owns it (Object.hasOwn).
  • Use a Map for data with user-chosen keys, or an object without a prototype, created with Object.create(null).
  • Validate parsed input against a schema that allows only the expected keys, and keep libraries that merge or clone up to date; many have had prototype pollution fixes.
  • As extra hardening, Object.freeze(Object.prototype) at startup makes pollution fail, and Node.js can remove the __proto__ accessor with --disable-proto=delete. Test this first: some older libraries depend on modifying prototypes.
const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]);

function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (FORBIDDEN.has(key)) continue;
    const value = source[key];
    if (typeof value === "object" && value !== null) {
      if (!Object.hasOwn(target, key) || typeof target[key] !== "object") {
        target[key] = {};
      }
      safeMerge(target[key], value);
    } else {
      target[key] = value;
    }
  }
  return target;
}

const body = '{"theme": "dark", "__proto__": {"isAdmin": true}}';
const preferences = safeMerge({ theme: "light" }, JSON.parse(body));
console.log(preferences);
console.log({}.isAdmin);

const lookup = Object.create(null);
lookup.theme = "dark";
console.log("toString" in lookup, "toString" in {});
{ theme: 'dark' }
undefined
false true

The polluting key is skipped, so {}.isAdmin stays undefined. The last line shows why Object.create(null) helps: such an object has no prototype, so it does not even inherit toString, and properties planted on Object.prototype do not show up in it either.

Secrets in front-end code

Anything that reaches the browser can be read by the user: the scripts, their source maps, the network requests, web storage and every value that a build tool baked into the bundle. Minifying or obfuscating code does not change that; it only slows a reader down by minutes. So:

  • No secret API keys in front-end code. If a browser script calls an API with a secret API key, that key is public. Let the browser call your own server, which adds the key and forwards the request. Keys that are designed to be public (for example a maps key restricted to your domain) are the exception, and their documentation says so.
  • Build-time variables become public. Bundlers can replace names such as import.meta.env.VITE_API_URL with their values; whatever they insert ends up in the bundle. Only put values there that anyone may see.
  • Tokens in localStorage are readable by every script on the page, including an injected one. A session in an HttpOnly cookie cannot be read by JavaScript at all (see tokens in browsers).

On the server, Node.js code reads secrets from environment variables or a secrets manager, never from a file in the repository (see what never belongs in Git). When a secret is missing, fail closed: stop with a clear error instead of running on with an empty value.

const apiKey = process.env.KESTREL_METER_API_KEY;

if (!apiKey) {
  throw new Error("KESTREL_METER_API_KEY is not set; refusing to start");
}
console.log("Connecting to the meter API");
Error: KESTREL_METER_API_KEY is not set; refusing to start

Node.js on the server

On a server, JavaScript runs with the rights of the operating system account that started Node.js, so mistakes reach files, processes and the network. Three habits prevent the most common ones.

File paths from input

A file name taken from a request can contain ../ to climb out of the intended folder (path traversal). Resolve the full path, then check that it is still inside the folder:

const path = require("node:path");

const REPORT_DIR = "/srv/kestrel/reports";

function reportPath(name) {
  const full = path.resolve(REPORT_DIR, name);
  if (!full.startsWith(REPORT_DIR + path.sep)) {
    throw new Error(`Refused path: ${name}`);
  }
  return full;
}

for (const name of ["2026-10.csv", "archive/2026-09.csv", "../config/.env", "/etc/passwd"]) {
  try {
    console.log(reportPath(name));
  } catch (error) {
    console.log(error.message);
  }
}
/srv/kestrel/reports/2026-10.csv
/srv/kestrel/reports/archive/2026-09.csv
Refused path: ../config/.env
Refused path: /etc/passwd

Running other programs

child_process.exec() passes a whole command line to a shell, so a value with ; or | in it can add commands of its own (command injection). execFile() starts the program directly and passes the arguments as a list that no shell interprets. Validate the value too:

const { execFile } = require("node:child_process");

// Unsafe: exec(`ping -c 1 ${host}`) lets a shell interpret host

// Safer: validated value, no shell, arguments as a list
if (!/^[a-z0-9.-]{1,253}$/i.test(host)) {
  throw new Error("Invalid host name");
}
execFile("ping", ["-c", "1", host], (error, stdout) => {
  if (error) {
    console.error("ping failed:", error.message);
    return;
  }
  console.log(stdout);
});

Least privilege

Run Node.js services as an unprivileged account, never as root. Since Node.js 22.13 and 23.5, the stable permission model adds a limit inside the process: started with --permission, Node.js refuses file system access, child processes, worker threads, native addons and (since Node.js 25) network access unless you allow them explicitly. The Node.js documentation describes it as a seat belt against mistakes in trusted code, not as a sandbox for malicious code.

node --permission --allow-fs-read=/srv/kestrel/reports report-server.js

Dependencies and the supply chain

A typical JavaScript project installs hundreds of packages, most of them dependencies of dependencies (see modules and npm). Each one is code that runs with the full rights of your build, your server or your users' browsers. A supply chain attack targets exactly that trust, and the npm registry has seen several:

  • ua-parser-js (October 2021): the account of the maintainer of a widely used package was hijacked, and for about four hours three published versions installed a cryptominer and a password stealer.
  • chalk, debug and others (September 2025): a maintainer was phished with a fake "update your two-factor authentication" email, and malicious versions of 18 very popular packages were published, reportedly with about two billion weekly downloads combined. The code tried to redirect cryptocurrency payments in browsers. They were removed the same day.
  • Shai-Hulud (from September 2025): a self-replicating worm spread through compromised maintainer accounts, stole secrets with install scripts and published itself into more packages; GitHub removed more than 500 of them. A second wave in November 2025 reportedly affected hundreds more packages and tens of thousands of GitHub repositories.

Safe habits with npm

  • Commit the lock file and install with npm ci in pipelines and on servers. It installs exactly the versions in package-lock.json and stops with an error if that file and package.json disagree, so a new, possibly malicious version does not slip in unnoticed.
  • Check a package before you add it: is it the right name (attackers publish look-alike names, "typosquatting"), is it maintained, how many dependencies does it pull in? A few lines of your own code can be safer than a tiny package.
  • Be careful with install scripts. Packages can run preinstall and postinstall scripts during npm install, which is how several worms spread. npm 12 (July 2026) no longer runs dependency install scripts by default and lets you approve the ones you need with npm approve-scripts. Node.js still bundles npm 11 as of 2026; there, npm install --ignore-scripts (or the ignore-scripts setting) blocks them.
  • Update deliberately, with a tool such as Dependabot proposing updates as pull requests that your tests check.
  • If you publish packages: two-factor authentication on the account, and publishing from CI with trusted publishing (OIDC) instead of long-lived tokens. npm revoked all classic tokens in December 2025.

npm audit

npm audit is the npm command that compares the exact package versions in your project's lock file with a database of published security advisories, and reports each vulnerable package with its severity and whether a fixed version is available.

npm audit
# npm audit report

ua-parser-js  0.7.29 || 0.8.0 || 1.0.0
Severity: critical
Embedded malware in ua-parser-js - https://github.com/advisories/GHSA-pjwm-rvh2-c87w
fix available via `npm audit fix`
node_modules/ua-parser-js

1 critical severity vulnerability

To address all issues, run:
  npm audit fix
  • npm audit fix updates to fixed versions within the ranges in package.json. npm audit fix --force also installs new major versions, which can break your code; review and test that change.
  • Not every finding matters equally: a vulnerability in a test tool that never touches user input is less urgent than one in your web server. npm audit --omit=dev limits the report to what goes into production. Prioritize as described in vulnerability management.
  • npm audit only knows published advisories. A freshly hijacked package is unknown for the first hours, which is exactly when it does its damage. The habits above protect you in that window; the audit does not.

In a pipeline, the same checks run as software composition analysis.

Subresource Integrity

A script loaded from a content delivery network (CDN) runs with full access to your page, so a compromised CDN file compromises your site. Subresource Integrity (SRI) lets you pin the exact file: the integrity attribute of a <script> or stylesheet <link> holds a cryptographic hash of the expected content, and the browser refuses to use the file if its hash differs. You can compute the value yourself:

const { createHash } = require("node:crypto");

const script = 'console.log("Kestrel outage map loaded");\n';
const digest = createHash("sha384").update(script).digest("base64");
console.log(`sha384-${digest}`);
sha384-ca8j3BkayXZG9q9XZ6ZJlFJhL4gCfYiFCJbv9W/iH3X7unma16qE6vkOgGCqZS4M
<script
  src="https://cdn.example.com/outage-map/2.4.1/map.min.js"
  integrity="sha384-ca8j3BkayXZG9q9XZ6ZJlFJhL4gCfYiFCJbv9W/iH3X7unma16qE6vkOgGCqZS4M"
  crossorigin="anonymous"></script>

Pin a specific version in the URL, because any change to the file, even a harmless update, breaks the hash. The crossorigin="anonymous" attribute is needed so the browser may check a file from another origin. Hosting the file yourself, as part of your own build, avoids the question altogether.

A short checklist

  • Untrusted text goes in with textContent; URLs pass a scheme allowlist.
  • Raw HTML only through a sanitizer, ideally behind Trusted Types.
  • No eval(), new Function() or string timers; data via JSON.parse().
  • A Content Security Policy that blocks inline scripts and eval().
  • Merge and clone code skips __proto__, constructor and prototype; user-keyed data in a Map.
  • No secrets in front-end code; every check repeated on the server.
  • Lock file committed, npm ci in pipelines, install scripts under control, npm audit and update tooling running.
  • CDN scripts pinned with Subresource Integrity, or self-hosted.