JSON Toolkit

How XSS happens through JSON

Bottom line: JSON never executes by itself. XSS happens at the moment you treat it as HTML — server-side string concatenation, frontend innerHTML, or inlining JSON straight into a script tag.

Intermediate~4 min readPublished 2026-10-30Updated 2026-10-30
Three ways JSON becomes XSS and how to prevent them

Bottom line: JSON never executes on its own — close the three output paths first: escape server-side by context, use textContent instead of innerHTML, and replace < with \u003c when inlining JSON.

{"name": "<img src=x onerror=alert(1)>"} is valid data. Whether it is dangerous depends entirely on where you put it.

Entry one: server-side string building

res.send(`<div>Hello, ${data.name}</div>`);

The browser parses the tags inside data.name. JSON here is just the container; the fault is escaping for the wrong context.

Fix: use a templating engine's default escaping (<%= %> in EJS, {{ }} in Vue and Django). Avoid <%- %>, v-html and |safe for anything user-controlled.

Entry two: innerHTML

el.innerHTML = user.bio;    // parses markup
el.textContent = user.bio;  // inserts text

React's {user.bio} and Vue's {{ user.bio }} create text nodes and are safe by default. dangerouslySetInnerHTML and v-html are explicit opt-outs.

If you genuinely need rich text, sanitise with an allowlist (DOMPurify and similar) rather than trusting the backend.

Entry three: inlining into a script tag

<script>
  window.__DATA__ = {"name": "x</script><script>alert(1)</script>"};
</script>

A </script> inside the data closes the block early and everything after it runs as a new script. This is the most-missed case.

JSON.stringify(data)
  .replace(/</g, '\\u003c')
  .replace(/>/g, '\\u003e')
  .replace(/&/g, '\\u0026');

Still valid JSON, identical after parsing, no longer able to close the tag. Next.js and Nuxt do the same for __NEXT_DATA__ and __NUXT__.

Two neighbours worth knowing

JSON hijacking — old browsers executed JSON loaded via <script src>; redefining Array leaked the contents. Modern browsers fixed it, and Content-Type: application/json plus X-Content-Type-Options: nosniff closes the rest.

JSONP leftovers — a user-controlled callback name is arbitrary code execution by design. Do not use it in new systems.

Checklist

  • Every place a JSON field reaches HTML is escaped for its context (HTML, attribute, JS, URL)
  • No innerHTML, v-html or dangerouslySetInnerHTML consuming API strings
  • Inlined JSON escapes <, > and &
  • Rich text goes through allowlist sanitisation, not blacklist filtering
  • Responses carry Content-Type: application/json and nosniff

FAQ

Why can valid JSON still trigger XSS?

The JSON is only text and never executes; being valid just means it parses. The danger appears when it is written into an HTML or script context: the browser parses the tags, or a </script> closes the block early and the rest runs as code. The fault is the missing context-aware escaping at output, not the data format.

Should I use textContent or innerHTML to render API fields?

Prefer textContent: it inserts the value as plain text, so tags are displayed literally. innerHTML parses markup, so an API field containing <img onerror=...> executes. React's {user.bio} and Vue's {{ user.bio }} create text nodes by default; dangerouslySetInnerHTML and v-html are explicit opt-outs.

How should I escape JSON inlined into a <script> tag?

Yes. A </script> inside the data closes the block early and everything after it runs as a new script. After serialising, replace <, > and & with \u003c, \u003e and \u0026: the result is still valid JSON, parses identically, and can no longer close the tag.

Further reading