JavaScript Security Basics
Writing JavaScript that cannot be turned against its users: avoiding cross-site scripting with safe DOM methods, never using eval on input, prototype pollution, secrets in front-end code, and safe dependencies.
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
#.
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("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
const comment = 'Outage at <b>"Den Haag"</b> & Delft';
console.log(`<p class="comment">${escapeHtml(comment)}</p>`);
<p class="comment">Outage at <b>"Den Haag"</b> & 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__,constructorandprototype, and only follow a key into a nested object when the target owns it (Object.hasOwn). -
Use a
Mapfor data with user-chosen keys, or an object without a prototype, created withObject.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_URLwith their values; whatever they insert ends up in the bundle. Only put values there that anyone may see. -
Tokens in
localStorageare readable by every script on the page, including an injected one. A session in anHttpOnlycookie 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 ciin pipelines and on servers. It installs exactly the versions inpackage-lock.jsonand stops with an error if that file andpackage.jsondisagree, 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
preinstallandpostinstallscripts duringnpm 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 withnpm approve-scripts. Node.js still bundles npm 11 as of 2026; there,npm install --ignore-scripts(or theignore-scriptssetting) 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 fixupdates to fixed versions within the ranges inpackage.json.npm audit fix --forcealso 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=devlimits the report to what goes into production. Prioritize as described in vulnerability management. -
npm auditonly 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 viaJSON.parse(). -
A Content Security Policy that blocks inline scripts and
eval(). -
Merge and clone code skips
__proto__,constructorandprototype; user-keyed data in aMap. - No secrets in front-end code; every check repeated on the server.
-
Lock file committed,
npm ciin pipelines, install scripts under control,npm auditand update tooling running. - CDN scripts pinned with Subresource Integrity, or self-hosted.