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

TypeWhere the payload livesTypical example
storedyour database, served to everyonea comment containing markup
reflectedthe URL, served back in the responsea search term echoed into the page
DOM-basednever touches the serverlocation.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.

SinkInterprets input asSafe alternative
el.innerHTML / outerHTMLmarkuptextContent, or sanitise first
el.insertAdjacentHTMLmarkupbuild nodes with createElement
document.writemarkupnever, in any circumstance
iframe.srcdoca whole documenta real URL you control
a.href, form.action, img.srca URL, including javascript:allowlist the scheme
el.setAttribute('on...')codeaddEventListener
eval, new Function, setTimeout(string)codepass a function, parse with JSON.parse
el.style.cssTextCSS, which can load URLsset individual properties
const escapeHtml = (value) =>
  String(value)
    .replaceAll('&', '&amp;')     // must come first
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');

console.log(escapeHtml('<img src=x onerror="alert(1)">'));
// -> &lt;img src=x onerror=&quot;alert(1)&quot;&gt;

console.log(escapeHtml("O'Reilly & Sons"));
// -> O&#39;Reilly &amp; Sons

// Escaping ampersand last would double-escape everything you just produced:
const wrong = (v) => String(v).replaceAll('<', '&lt;').replaceAll('&', '&amp;');
console.log(wrong('<b>'));   // -> &amp;lt;b&amp;gt; ... broken output

Escaping, 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 escape

The 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 true

Three defences, in order of strength.

lookup tables
Object.create(null) or a Map, always
reading a dynamic key
Object.hasOwn(obj, key) before you trust it
merging untrusted data
allowlist the keys you accept, do not blocklist the ones you fear
freezing
Object.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.

DirectiveStops
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 attributeEffectUse
HttpOnlyinvisible to document.cookiealways, for session cookies
SecureHTTPS onlyalways
SameSite=Strictnever sent cross-sitesensitive apps, breaks inbound links
SameSite=Laxsent on top-level navigation onlythe sensible default
SameSite=Nonesent everywhere, requires Secureonly for genuine third-party embeds
Path and Domainnarrow the scopekeep 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 ci in 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 &amp;, &lt;, &gt;, &quot;, &#39;), 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 = userText dangerous even though injected <script> tags do not execute?
Inline event handler attributes such as onerror and onload do execute, as do javascript: 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. Use textContent, 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 with Object.create(null) has no prototype, so t['toString'] and t['__proto__'] are just ordinary missing keys and there is nothing to pollute. A Map is equally good. Calling hasOwnProperty on the object itself is fragile because that method can be shadowed by data (use Object.hasOwn instead), 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 (&lt;, &quot;), so if you escape & last you will re-escape the ones you just produced and render &amp;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.origin exactly, and validate the payload shape — Any page can message you, so you must compare event.origin against an exact expected string (a startsWith check is defeated by https://app.example.com.evil.net) and then validate the payload before acting on it. Checking event.source against the window you expect is a useful third check. On the sending side, pass a specific targetOrigin rather than *.

Common mistakes

  • Interpolating any dynamic value into an innerHTML string.
  • Believing innerHTML is 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 href or src without allowlisting the scheme.
  • A recursive merge or set(path, value) helper that accepts __proto__ or constructor.
  • 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.origin with startsWith or includes.
  • 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.
  • textContent and node construction remove the XSS class. innerHTML needs a justification every time.
  • Escape the ampersand first, or you double-escape your own output.
  • Allowlist URL schemes. javascript: and data:text/html are code, not links.
  • Prototype pollution turns a merge into global variable injection. Block __proto__, constructor and prototype, or allowlist keys.
  • Use Object.create(null) or a Map for 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, Secure and SameSite. Client code cannot keep a secret.
  • A dependency runs with your privileges. Lockfiles, exact versions and review are the whole defence.
  • Pin targetOrigin when sending and compare event.origin exactly when receiving.