Note · October 13, 2026

Escaped on the server, executed in the browser

A common pattern in internal dashboards turns correctly escaped data back into live HTML. Every step looks safe on its own.

There is a cross-site scripting pattern that survives code review because every individual step in it is correct. It shows up most often in internal dashboards, and internal dashboards are where it does the most harm.

The pattern

A server renders a list of records into a page. Each record carries some text that came from outside the system: a page path, a referrer, a user agent, a name typed into a form. The server does the right thing and escapes it:

<li data-agent="&lt;img src=x onerror=alert(1)&gt;">Visitor 41</li>

Later, a click handler wants to show that text in a detail panel. It reads the attribute and puts it on the page:

panel.innerHTML = `<strong>Agent:</strong> ${row.dataset.agent}`;

The script runs.

Why it happens

HTML escaping is undone by the parser that reads it. When the browser parses data-agent="&lt;img…&gt;", it decodes the entities, and dataset.agent returns the original string, <img src=x onerror=alert(1)>. The escaping did its job, which was to keep the attribute intact in the HTML. It was never meant to survive being read back.

So the data was escaped once, decoded once, and then handed to innerHTML as markup. Encode, decode, inject: each step looks safe on its own, and the combination is a hole.

Why internal tools are the worst case

"It's only for us" is the usual reason this gets waved through. It has the risk backwards.

A dashboard that displays requests, sign-ups or form submissions exists to show data that strangers control. And the person looking at it is usually the one with the most access to everything else. Script running in that page runs with that person's session.

The fix

Don't escape more carefully. Build the page so the bug can't be written:

const label = document.createElement('strong');
label.textContent = 'Agent: ';
const value = document.createElement('span');
value.textContent = row.dataset.agent;
panel.replaceChildren(label, value);

textContent never parses markup, so it doesn't matter what the string contains or how many times it was encoded along the way.

Reserve innerHTML for markup that contains no interpolated values at all. If a review tool flags an innerHTML assignment, ask one question before dismissing it: can any value in this string have come from outside the system? If the answer is "only after escaping", follow the value all the way to the assignment and check that nothing decoded it on the way.

Escaping is a rule people have to keep remembering, and it erodes under refactoring. A structure where the hole can't be expressed doesn't erode.

Contact

Have something that needs building, or a system that needs a second look? Tell us what you are working on.

[email protected]