Anything you drop into element.innerHTML gets parsed as HTML. That includes event handlers, script tags, and everything else the browser's HTML parser will happily execute for you.
Payloads
// user input goes straight into innerHTML const name = input.value; div.innerHTML = 'Hello, ' + name + '!';
// textContent never touches the HTML parser const p = document.createElement('p'); p.textContent = 'Hello, ' + input.value + '!'; div.appendChild(p);
textContent skips the parser entirely. The value is treated as a raw string; angle brackets become < on screen, not DOM nodes. If you're setting text, use textContent. The only time innerHTML is acceptable with dynamic content is when that content has already been sanitized through something like DOMPurify.
setAttribute('onclick', string) writes JavaScript source code into the attribute. The browser compiles and runs it when the element is clicked. User input in that string means the user writes part of your program.
Payloads
const msg = input.value; btn.setAttribute('onclick', 'alert("' + msg + "')"); // msg = `"); evil(); ("` → onclick becomes: alert(""); evil(); ("")
// pass the value as data, not source code const msg = input.value; btn.addEventListener('click', () => alert(msg)); // msg stays a variable the browser never parses it as JS
setAttribute('onclick', s), s is compiled by the JS engine at click time. That's fundamentally different from addEventListener, which takes a function reference the value never gets near the parser. The break-out is straightforward: close the quote that wraps the intended value, inject a statement terminator, then write whatever code you want. Quote escaping alone isn't a fix different contexts allow different escape sequences. The only real fix is never building event handler strings from user input.
The browser treats javascript: as an executable URL scheme. Set a link's href to it and clicking the link runs whatever follows the colon in the context of the current page, with full access to the DOM.
Payloads
const url = input.value; link.href = url; // javascript:alert(1) is a valid href value
// allowlist the scheme don't try to blocklist javascript: function safeUrl(url) { try { const { protocol } = new URL(url); return ['https:', 'http:'].includes(protocol); } catch { return false; } } if (safeUrl(input.value)) link.href = input.value;
javascript: is a scheme the browser navigates to by executing code. It runs in the current page context with full DOM and cookie access. It predates most modern security thinking and browsers still support it for legacy reasons. You can't reliably filter it with string matching because of encoding tricks: javascript:, javascript:, whitespace before the colon. An allowlist on the scheme is the only solid approach accept https: and http:, reject everything else.
document.write dumps a raw HTML string into the document stream. Same problem as innerHTML but it looks like a print statement so it gets missed in reviews. You'll find it most in legacy search result pages that reflect the query back.
Payloads
document.write('Results for: ' + input.value);
const p = document.createElement('p'); p.textContent = 'Results for: ' + input.value; container.appendChild(p);
document.write writes into the live HTML parser stream. It has no encoding layer, no context awareness. The string you pass is processed exactly as if it appeared in your source file. It's a deprecated API that exists purely for legacy compatibility there's no modern use case that requires it. Anywhere you see it in a codebase, especially near URL parameters or user-controlled data, treat it as a finding.
Stored XSS persists. The payload gets saved and re-executed for every user who loads the page. This board uses localStorage to simulate it post a payload, reload, watch it fire again without any interaction. In a real app it would hit every visitor until someone deletes the record.
Payloads paste into the comment field
// raw stored data rendered with innerHTML comments.forEach(c => { container.innerHTML += `<div><b>${c.name}</b><p>${c.body}</p></div>`; });
// build DOM nodes nothing gets parsed as HTML comments.forEach(c => { const div = document.createElement('div'); const name = document.createElement('b'); const body = document.createElement('p'); name.textContent = c.name; body.textContent = c.body; div.appendChild(name); div.appendChild(body); container.appendChild(div); });
textContent on read (client-side). Either one alone can still be bypassed. Both together close the surface.