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.

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-htmlordangerouslySetInnerHTMLconsuming API strings - Inlined JSON escapes
<,>and& - Rich text goes through allowlist sanitisation, not blacklist filtering
- Responses carry
Content-Type: application/jsonandnosniff
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.