Security Essentials
Mental model: Every security bug in front-end JavaScript is the same bug: data from outside your program was treated as instructions instead of as text.
Level: advanced · about 20 minutes
You do not need to be a security specialist to close the holes that actually ship. Almost all of them come from one confusion: a string arrives from a user, a URL, an API or a message, and somewhere downstream it gets parsed as markup, as code, or as a property name. Learn the sinks, learn to escape at the boundary, and you have removed the majority of real-world front-end vulnerabilities.
XSS: three flavours, one cause
| Type | Where the payload lives | Typical example |
|---|---|---|
| stored | your database, served to everyone | a comment containing markup |
| reflected | the URL, served back in the response | a search term echoed into the page |
| DOM-based | never touches the server | location.hash written into innerHTML |
const userInput = '<img src=x onerror="steal()">';
// This is what an unsafe render produces:
const unsafe = `<div class="comment">${userInput}</div>`;
console.log(unsafe);
// -> <div class="comment"><img src=x onerror="steal()"></div>
// A <script> tag inserted via innerHTML does NOT execute, which is where
// the dangerous myth comes from. These all do:
for (const payload of [
'<img src=x onerror=alert(1)>',
'<svg onload=alert(1)>',
'<iframe srcdoc="<script>alert(1)</script>">',
'<a href="javascript:alert(1)">click</a>',
'<body onpageshow=alert(1)>',
]) {
console.log('executes:', payload);
}The payload people expect, and the payload that actually works.
| Sink | Interprets input as | Safe alternative |
|---|---|---|
el.innerHTML / outerHTML | markup | textContent, or sanitise first |
el.insertAdjacentHTML | markup | build nodes with createElement |
document.write | markup | never, in any circumstance |
iframe.srcdoc | a whole document | a real URL you control |
a.href, form.action, img.src | a URL, including javascript: | allowlist the scheme |
el.setAttribute('on...') | code | addEventListener |
eval, new Function, setTimeout(string) | code | pass a function, parse with JSON.parse |
el.style.cssText | CSS, which can load URLs | set individual properties |
const escapeHtml = (value) =>
String(value)
.replaceAll('&', '&') // must come first
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
console.log(escapeHtml('<img src=x onerror="alert(1)">'));
// -> <img src=x onerror="alert(1)">
console.log(escapeHtml("O'Reilly & Sons"));
// -> O'Reilly & Sons
// Escaping ampersand last would double-escape everything you just produced:
const wrong = (v) => String(v).replaceAll('<', '<').replaceAll('&', '&');
console.log(wrong('<b>')); // -> &lt;b&gt; ... broken outputEscaping, in the only correct order: ampersand first.
The sink
container.innerHTML =
'<li>' + comment.text + '</li>';
// one unescaped field is enough
No sink at all
const li = document.createElement('li');
li.textContent = comment.text;
container.append(li);
// textContent cannot create an
// element, so there is nothing
// to escapeThe structural fix is to stop producing markup strings. Build nodes and assign text. When you genuinely need rich HTML from user content, sanitise with a maintained library (DOMPurify) or the built-in Element.prototype.setHTML, which is now shipping in Chromium-based browsers and landing elsewhere. Never with your own regex.
Prototype pollution
Every plain object inherits from Object.prototype. If an attacker can get you to write to a key called __proto__, constructor or prototype, they are not editing your object, they are editing the prototype every object in the program shares. That turns a config merge into a global variable injection.
function merge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] ??= {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
const config = merge({}, JSON.parse('{"__proto__":{"isAdmin":true}}'));
console.log({}.isAdmin, config.isAdmin);JSON.parse creates a real own property named __proto__ (it does not go through the setter), so Object.keys reports it. The naive merge then reads target.__proto__, which is Object.prototype, finds it is not nullish, and recurses into it, writing isAdmin: true onto the prototype every object shares. Now {}.isAdmin is true for the rest of the process, and any if (user.isAdmin) check in the codebase passes. This is a real, repeatedly exploited bug class in merge, clone and query-string libraries.
const BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
// 1. Reject dangerous keys at the boundary
function safeAssign(target, source) {
for (const key of Object.keys(source)) {
if (BLOCKED.has(key)) continue;
target[key] = source[key];
}
return target;
}
console.log(safeAssign({}, JSON.parse('{"a":1,"__proto__":{"bad":true}}'))); // -> { a: 1 }
// 2. Use an object with no prototype, so there is nothing to pollute
const bare = Object.create(null);
bare.a = 1;
console.log(Object.getPrototypeOf(bare), 'toString' in bare); // -> null false
// 3. Use a Map, where keys are just keys
const settings = new Map([['__proto__', 'harmless string']]);
console.log(settings.get('__proto__'), Object.getPrototypeOf({}) === Object.prototype);
// -> harmless string trueThree defences, in order of strength.
lookup tablesObject.create(null)or aMap, alwaysreading a dynamic keyObject.hasOwn(obj, key)before you trust itmerging untrusted data- allowlist the keys you accept, do not blocklist the ones you fear
freezingObject.freeze(Object.prototype)at startup is a blunt but effective backstop
Content Security Policy
A CSP is a response header that tells the browser which sources of script, style and connection to trust. It does not fix your bugs, it limits what a successful injection can do, which is why it belongs in the "defence in depth" column rather than the "instead of escaping" column.
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-r4nd0m' 'strict-dynamic';
style-src 'self';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
object-src 'none';
base-uri 'self';
require-trusted-types-for 'script';A realistic starting policy. Nonces beat allowlisting hostnames.
| Directive | Stops |
|---|---|
script-src without 'unsafe-inline' | injected inline handlers and <script> blocks |
script-src without 'unsafe-eval' | eval, new Function, setTimeout(string) |
object-src 'none' | legacy plugin-based bypasses |
base-uri 'self' | a <base> tag rewriting all your relative URLs |
frame-ancestors 'none' | clickjacking by embedding your page |
require-trusted-types-for 'script' | unsanitised assignment to innerHTML and friends, at runtime |
Cookies, tokens and secrets
| Cookie attribute | Effect | Use |
|---|---|---|
HttpOnly | invisible to document.cookie | always, for session cookies |
Secure | HTTPS only | always |
SameSite=Strict | never sent cross-site | sensitive apps, breaks inbound links |
SameSite=Lax | sent on top-level navigation only | the sensible default |
SameSite=None | sent everywhere, requires Secure | only for genuine third-party embeds |
Path and Domain | narrow the scope | keep both as tight as possible |
Dependency risk
- A dependency runs with the same privileges as your code. There is no sandbox.
- Install scripts run at install time, before you have read a single line.
- Typosquatting is cheap: check the package name character by character before adding it.
- Commit the lockfile and use
npm ciin CI, so builds are reproducible and a republished version cannot swap under you. - Pin exact versions for anything security-sensitive, and review the diff when you bump.
- Prefer fewer, larger, well-maintained dependencies over a graph of one-line packages.
- Audit regularly (
npm audit, or a scanner in CI) and treat transitive dependencies as your own.
Safe cross-origin messaging
Wide open
// sender
iframe.contentWindow
.postMessage(token, '*');
// receiver
window.addEventListener(
'message',
(e) => apply(e.data)
);
// any page that embeds you can
// send anything, and your token
// goes to whoever is in the frame
Pinned both ways
// sender: name the recipient
iframe.contentWindow
.postMessage(token, 'https://app.example.com');
// receiver: check the sender
window.addEventListener('message', (e) => {
if (e.origin !== 'https://app.example.com') return;
if (e.source !== expectedWindow) return;
if (typeof e.data?.type !== 'string') return;
apply(e.data);
});Always pass a specific targetOrigin instead of *, always compare event.origin against an exact string (never startsWith, which https://app.example.com.evil.net defeats), and validate the shape of the payload before using it.
Try it yourself
Pollute, then defend
const BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
function guardedMerge(target, source) {
for (const key of Object.keys(source)) {
if (BLOCKED.has(key)) {
console.log('blocked key:', key);
continue;
}
const value = source[key];
if (value && typeof value === 'object' && !Array.isArray(value)) {
target[key] = guardedMerge(target[key] ?? {}, value);
} else {
target[key] = value;
}
}
return target;
}
const payload = JSON.parse('{"theme":"dark","__proto__":{"isAdmin":true},"nested":{"a":1}}');
const config = guardedMerge({}, payload);
console.log(config); // -> { theme: 'dark', nested: { a: 1 } }
console.log('polluted?', {}.isAdmin); // -> polluted? undefined
// A lookup table with nothing to inherit
const table = Object.create(null);
table.tea = 250;
console.log('tea' in table, 'toString' in table, table.__proto__);
// -> true false undefined
Add constructor and prototype to the payload and check whether your guard still holds. Then rewrite guardedMerge to allowlist known config keys instead of blocking dangerous ones.
Which URLs would you render?
const SAFE_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:']);
function isSafeUrl(url) {
if (typeof url !== 'string') return false;
// Browsers strip control characters and whitespace before parsing the scheme
const cleaned = url.replace(/[\u0000-\u001F\u007F\s]/g, '');
if (!cleaned) return false;
const match = /^([a-z][a-z0-9+.-]*):/i.exec(cleaned);
if (!match) return true; // relative URL
return SAFE_SCHEMES.has(match[1].toLowerCase() + ':');
}
for (const url of [
'https://example.com/a',
'/about',
'#section',
'mailto:hi@example.com',
'javascript:alert(1)',
' JaVaScRiPt:alert(1)',
'java\tscript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox(1)',
]) {
console.log(isSafeUrl(url) ? 'allow ' : 'block ', JSON.stringify(url));
}
Add support for allowing data:image/png but nothing else with a data: scheme. Then decide what your app should do with protocol-relative URLs like //cdn.example.com/x.js.
Exercises
A merge that cannot be polluted
Write safeMerge(target, source) returning a new object. Merge plain objects recursively, replace everything else (including arrays) by value, and skip the keys __proto__, constructor and prototype entirely. Neither argument may be mutated, and Object.prototype must be untouched afterwards.
Escape and allowlist
Write two functions. escapeHtml(value) coerces to a string and escapes &, <, >, " and ' (as &, <, >, ", '), with the ampersand handled first so nothing is double-escaped. isSafeUrl(url) returns true only for relative URLs and the schemes http:, https:, mailto: and tel:, after stripping whitespace and control characters, and false for anything else including non-strings and the empty string.
Check yourself
- Why is
el.innerHTML = userTextdangerous even though injected<script>tags do not execute? - Inline event handler attributes such as
onerrorandonloaddo execute, as dojavascript:URLs — The HTML parser skips<script>elements inserted this way, which is where the false sense of safety comes from. It happily attaches inline handlers, so<img src=x onerror=...>runs immediately and<svg onload=...>does the same. UsetextContent, build nodes, or sanitise with a maintained library. - Which lookup is immune to prototype pollution and prototype-key confusion?
const t = Object.create(null); t[key]— An object created withObject.create(null)has no prototype, sot['toString']andt['__proto__']are just ordinary missing keys and there is nothing to pollute. AMapis equally good. CallinghasOwnPropertyon the object itself is fragile because that method can be shadowed by data (useObject.hasOwninstead), and the fourth option is not valid code.- What is the correct order when escaping HTML?
- the ampersand first, then the rest — Every other replacement introduces an ampersand (
<,"), so if you escape&last you will re-escape the ones you just produced and render&lt;instead of<. Ampersand first is the only safe order, and it is the classic off-by-one of hand-written escaping. - Which pair of checks does every
window.addEventListener('message', ...)handler need? - check
event.originexactly, and validate the payload shape — Any page can message you, so you must compareevent.originagainst an exact expected string (astartsWithcheck is defeated byhttps://app.example.com.evil.net) and then validate the payload before acting on it. Checkingevent.sourceagainst the window you expect is a useful third check. On the sending side, pass a specifictargetOriginrather than*.
Common mistakes
- Interpolating any dynamic value into an
innerHTMLstring. - Believing
innerHTMLis safe because<script>does not run. - Escaping the ampersand last, which double-escapes everything.
- Writing your own regex sanitiser instead of using a maintained library or
setHTML. - Assigning a user-supplied URL to
hreforsrcwithout allowlisting the scheme. - A recursive merge or
set(path, value)helper that accepts__proto__orconstructor. - Using a plain object as a lookup table keyed by user input.
- Storing auth tokens in
localStorage, where any XSS reads them instantly. - Shipping an API key in the bundle and assuming minification hides it.
- Comparing
event.originwithstartsWithorincludes. - Treating a CSP as a substitute for escaping rather than a second layer.
Takeaways
- Every front-end security bug is untrusted data being parsed as markup, code or a property name.
textContentand node construction remove the XSS class.innerHTMLneeds a justification every time.- Escape the ampersand first, or you double-escape your own output.
- Allowlist URL schemes.
javascript:anddata:text/htmlare code, not links. - Prototype pollution turns a merge into global variable injection. Block
__proto__,constructorandprototype, or allowlist keys. - Use
Object.create(null)or aMapfor any table keyed by outside input. - A CSP limits the damage of a bug you missed. It is a second layer, not a first one.
- Cookies want
HttpOnly,SecureandSameSite. Client code cannot keep a secret. - A dependency runs with your privileges. Lockfiles, exact versions and review are the whole defence.
- Pin
targetOriginwhen sending and compareevent.originexactly when receiving.