Skip to content
Security

5 JSON-in-HTML Escaping Tests Before Shipping Frontend Data

·11 min read
5 JSON-in-HTML Escaping Tests Before Shipping Frontend Data - Security guide from JSON Prettifier

Quick answer: Test embedded JSON at both parser boundaries. First, serialize values as JSON. Then apply the encoding required by the exact HTML location: script data, a quoted attribute, or DOM text. Confirm the rendered document keeps one intact data container, round-trips the original values, and never sends parsed strings into an HTML-interpreting sink.

Valid JSON can still produce unsafe HTML. The browser parses the surrounding document before your code calls JSON.parse(), so a character sequence that is harmless inside a JSON string can have meaning to the HTML tokenizer. The fix is not more validation of the JSON value; it is correct encoding for the destination context.

The examples below were executed with Node.js 24.19.0 and Cheerio 1.2.0, whose HTML parsing uses parse5. They are focused regression fixtures, not a browser security certification. For background, cross-site scripting is code injection into a trusted page; the WHATWG HTML parsing standard is the authority for script-data tokenization.

1. Does the script-data serializer neutralize HTML end tags?

Direct answer: After JSON.stringify(), escape every less-than sign as \u003c before placing the result directly in a script element. The escape remains valid JSON and decodes back to the original character, while the serialized HTML no longer contains a literal script end-tag opener. Test mixed case, surrounding text, ampersands, and ordinary Unicode values.

JSON.stringify() understands JSON string delimiters and control characters. It does not know that its output will be inserted into HTML. The HTML tokenizer, meanwhile, does not wait for the JavaScript or JSON parser to decide whether </script> is inside a quoted string.

Use a narrow serializer at this boundary:

export function jsonForHtmlScript(value) {
  return JSON.stringify(value)
    .replaceAll("<", "\\u003c")
    .replaceAll(">", "\\u003e")
    .replaceAll("&", "\\u0026");
}

Escaping < is the critical step for preventing a literal closing-tag opener. Escaping > and & is a conservative defense-in-depth convention that also keeps HTML-looking text out of page source. These are JSON Unicode escapes, not HTML entities, so JSON.parse() restores the original strings:

import assert from "node:assert/strict";

const value = {
  title: "Sale </ScRiPt><script>window.demo=1</script>",
  note: "A & B > C",
  city: "Ahmedabad",
};
const encoded = jsonForHtmlScript(value);

assert.equal(encoded.includes("<"), false);
assert.equal(encoded.includes("</script"), false);
assert.deepEqual(JSON.parse(encoded), value);

Do not escape by deleting characters or replacing the complete attack string. A value may legitimately contain markup-like text, and HTML end-tag recognition is ASCII case-insensitive. Character-level JSON escapes preserve the data while removing the HTML-significant spelling.

The JSON Escape tool helps inspect JSON quoting and Unicode escapes. It does not decide whether output is safe in HTML, an attribute, a URL, CSS, or JavaScript code; each destination has a different parser and encoding rule.

For the underlying JSON-string rules, the JSON escape-character guide separates required JSON escapes from this additional HTML-context step.

2. Does a non-executable JSON script block remain intact?

Direct answer: Treat <script type="application/json"> and JSON-LD blocks as HTML script elements even though browsers do not execute their contents. Escape the JSON for script-data context, parse the rendered document, and assert that the element contains the complete serialized value. The MIME-like type changes execution behavior, not HTML end-tag tokenization.

A common pattern keeps initial application state in a data block:

<script id="page-data" type="application/json">{"user":{"name":"Asha"}}</script>

That is easier to separate from executable code, but it is not an escaping exemption. The current HTML standard classifies data blocks by the script element's type; the same surrounding element still has an HTML start tag and end tag.

This fixture demonstrates the broken and fixed structures:

import { load } from "cheerio";

const value = { message: "before </script><p id=breakout>after</p>" };
const unsafeHtml = `<script id="data" type="application/json">${JSON.stringify(value)}</script>`;
const safeHtml = `<script id="data" type="application/json">${jsonForHtmlScript(value)}</script>`;

const unsafe = load(unsafeHtml);
assert.equal(unsafe("#breakout").length, 1);
assert.throws(() => JSON.parse(unsafe("#data").text()));

const safe = load(safeHtml);
assert.equal(safe("#breakout").length, 0);
assert.deepEqual(JSON.parse(safe("#data").text()), value);

Read the block with textContent in browser code, not innerHTML. Text retrieval gives the JSON characters held by the element; HTML serialization can apply its own representation rules. Keep the element ID fixed rather than deriving selectors or markup from untrusted data.

The same boundary applies to search metadata. The JSON-LD structured-data guide now uses script-data escaping in its generated React example.

The September 23, 2026 reviewed serialize-javascript advisory concerned a specific version and function-body path; it explicitly says JSON mode and non-function values were unaffected. It is useful current evidence that script termination deserves regression tests, not evidence that every serializer or JSON block is vulnerable.

3. Does a quoted data attribute survive exactly one HTML decode?

Direct answer: For server-rendered attributes, JSON-stringify the value and then pass that string through the template engine's HTML-attribute encoder for the selected quote style. Test quotes, ampersands, less-than signs, and entity-looking text. After HTML parsing, read the attribute once and JSON-parse once; avoid manual partial escaping or repeated entity decoding.

The required layers are different:

  1. JSON serialization protects the JSON grammar.
  2. HTML attribute encoding protects the surrounding quoted attribute.
  3. The HTML parser decodes character references when it builds the DOM.
  4. JSON.parse() turns the resulting attribute string back into values.

Here is a deliberately small double-quoted attribute encoder for a test fixture:

function htmlDoubleQuotedAttribute(text) {
  return text
    .replaceAll("&", "&amp;")
    .replaceAll('"', "&quot;")
    .replaceAll("<", "&lt;");
}

const value = { label: 'Tea & "snacks" < 5', entity: "&quot;" };
const json = JSON.stringify(value);
const html = `<button id="buy" data-order="${htmlDoubleQuotedAttribute(json)}">Buy</button>`;
const $ = load(html);

assert.deepEqual(JSON.parse($("#buy").attr("data-order")), value);
assert.equal($("#buy").length, 1);

In application code, use the framework's documented attribute binding or a DOM method such as element.setAttribute("data-order", JSON.stringify(value)) with a fixed, safe attribute name. Do not copy the illustrative encoder into a general templating library: unquoted attributes, single-quoted attributes, event handlers, URLs, and srcdoc are different contexts.

Base64 is not a general escaping substitute. It adds size, still needs a clearly defined decoder, and can conceal unsafe downstream use. Encoding the correct context directly is simpler and reviewable.

4. Are parsed strings sent only to non-interpreting DOM sinks?

Direct answer: After JSON.parse(), treat every string according to its final destination. Use textContent for visible text, fixed-property assignments for ordinary values, and separately validated URL setters where needed. Do not pass a parsed string to innerHTML, outerHTML, insertAdjacentHTML(), an event-handler attribute, eval(), or the Function constructor.

JSON parsing proves syntax. It does not make the strings trustworthy or strip tags. The difference is visible in a compact DOM fixture:

const payload = JSON.parse('{"label":"<img src=x onerror=demo()>"}');

const safeDom = load("<div id=target></div>");
safeDom("#target").text(payload.label);
assert.equal(safeDom("#target img").length, 0);
assert.match(safeDom("#target").html(), /&lt;img/);

const unsafeDom = load("<div id=target></div>");
unsafeDom("#target").html(payload.label);
assert.equal(unsafeDom("#target img").length, 1);

Cheerio does not execute the handler; the assertion proves that the unsafe path creates an element and attribute instead of text. In a browser, HTML-interpreting sinks have additional security implications. MDN recommends creating nodes separately and assigning untrusted plain text through textContent. OWASP likewise emphasizes context-aware output encoding and safe sinks in its XSS prevention guidance.

If the product intentionally accepts user-authored HTML, define that as a separate feature. Use a maintained sanitizer with a reviewed allowlist, test URL protocols and mutation behavior, and keep the sanitized result away from contexts the sanitizer did not cover. JSON escaping is not HTML sanitization.

Review the broader JSON security practices separately for unsafe parsing, object merging, response-field exposure, and transport limits; HTML output encoding solves only one boundary.

5. Does the rendered page pass structural and policy checks?

Direct answer: Test the final generated HTML, not only the serializer function. Assert that the data element occurs once, its following siblings remain unchanged, its text parses to the expected object, no injected nodes appear, and security headers match the deployed page. Content Security Policy and Trusted Types add defense in depth but never replace correct encoding.

Templates, CMS filters, framework upgrades, and a second serialization pass can undo a safe helper. A rendered-artifact test catches the integrated result:

const state = {
  product: "Coffee </SCRIPT><img id=unexpected src=x>",
  description: "A & B",
};
const page = `<!doctype html>
<main id="app"></main>
<script id="state" type="application/json">${jsonForHtmlScript(state)}</script>
<footer id="site-footer">Ready</footer>`;

const $page = load(page);
assert.equal($page("#state").length, 1);
assert.equal($page("#unexpected").length, 0);
assert.equal($page("#site-footer").text(), "Ready");
assert.deepEqual(JSON.parse($page("#state").text()), state);
assert.equal((page.match(/<script\b/gi) ?? []).length, 1);
assert.equal((page.match(/<\/script\s*>/gi) ?? []).length, 1);

Run a browser-level version in your end-to-end suite because server parsers, hydration, and DOM mutations can differ from this local fixture. Include mixed-case closing tags, strings containing <!--, entity-looking text, quotes, ampersands, and international characters. Verify both page structure and value equality; a control that avoids execution by corrupting customer text is not complete.

Inspect the response bytes as well as the hydrated DOM. The source check proves what the server emitted; the DOM check proves how the browser interpreted it. Keep a harmless sentinel after the data block and assert that it remains the next expected element. Also capture page errors, blocked-policy reports, and hydration warnings. A test that checks only whether a global variable changed can miss broken markup that did not execute code but still removed content or shifted the component tree. Run the fixture through the same rendering mode used in production, including server-side rendering, static generation, streaming, or a CMS transform. When two paths render the same component, both paths need evidence because their escaping layers may differ.

A strict Content Security Policy can block unauthorized scripts. Trusted Types can restrict selected DOM injection sinks in supporting browsers. Neither mechanism changes the fact that malformed server HTML can terminate an element, add markup, or break hydration. Test policies in report-only mode before enforcement, then monitor violations without weakening the primary output encoding.

A September 18, 2026 developer report described JSON placed in a script block without closing-tag escaping. It supports the practical relevance of rendered-output tests; it does not establish that every template in that project, or every HTML serializer, shares the issue.

What should the JSON-in-HTML release checklist require?

Direct answer: Require one declared encoding path per destination and regression fixtures that cross every parser boundary. Reviewers should be able to identify where JSON serialization ends, where HTML encoding begins, how the browser retrieves the text, and which DOM sink receives each parsed string. Reject helpers whose safety depends on undocumented template behavior.

Use this compact matrix during release review:

BoundaryPositive assertionRequired adversarial fixture
Script serializerOriginal value round-trips</ScRiPt> and <script> in a string
Data blockOne intact non-executable elementClosing tag followed by injected markup
Quoted attributeAttribute parses once to the original valueQuotes, &quot;, ampersand, less-than
DOM displayValue becomes a text nodeImage tag with an event-handler attribute
Rendered pageExpected siblings, one block, valid policyTemplate filters and mixed-case end tags

Keep context in helper names: jsonForHtmlScript is clearer than safeJson, because the result is not automatically safe in an attribute, URL, CSS rule, or HTML sink. Prefer framework-maintained serializers and bindings when their security contract matches the destination, and lock their behavior with the same fixtures during upgrades.

Finally, separate confidentiality from injection safety. Initial-state JSON is visible to anyone who can load the page or view its source. Do not embed API secrets, private server fields, or authorization decisions. Minimize the payload before serialization, apply context-aware encoding, parse it as data, and keep every value in a non-interpreting sink unless the product deliberately defines a narrower trusted language.

Frequently Asked Questions

Is JSON.stringify safe inside an HTML script tag?
Not by itself when strings can contain a less-than sign. JSON.stringify produces valid JSON, but a literal closing script sequence can end the HTML element before JavaScript or JSON parsing begins. Use the framework's documented serializer for this context or replace less-than signs with the valid JSON escape \u003c.
Does type application/json prevent a closing script tag problem?
No. The type prevents the element's contents from executing as JavaScript, but the HTML parser still recognizes the script element's end tag. Escape dangerous less-than characters before inserting serialized JSON. Then read the element's textContent and pass that text to JSON.parse.
How should JSON be stored in an HTML data attribute?
Prefer setting a fixed data attribute through a DOM API, which handles serialization into the document. For server-rendered source, JSON-stringify the value and then apply the template engine's HTML-attribute encoder for the chosen quote context. Read the attribute once and call JSON.parse once.
Should parsed JSON values be inserted with innerHTML?
No when the value is intended as text. Parsing JSON does not sanitize its strings. Assign plain content through textContent or create DOM nodes and validated attributes explicitly. Use HTML sanitization only when the product intentionally accepts a restricted HTML language and has a reviewed policy.
Does Content Security Policy make unsafe JSON embedding acceptable?
No. Content Security Policy can restrict which scripts execute and is valuable defense in depth, but it does not repair a prematurely closed element or prevent every markup injection consequence. Keep context-aware escaping as the primary control, then test the deployed policy and avoid unsafe inline-script allowances.

Inspect the JSON Before It Enters a Template

Use the JSON Escape tool to inspect quotes, backslashes, and Unicode escapes. Apply the separate HTML-context encoding required by your template.

Open JSON Escape Tool