skip to content

In PHP, how do you pass data such as a list of reviews into an inline <script> block safely, and why do the JSON_HEX_* flags matter there?

level: seniorimportance: should knowfreq 30%

answer

  1. script body is not HTML-decoded
  2. </script> ends the block early
  3. JSON_HEX_TAG turns < into \u003C
  4. HEX_AMP, HEX_APOS, HEX_QUOT
  5. JSON_THROW_ON_ERROR on bad UTF-8

basics

~10 s

Encode the data with json_encode() plus JSON_HEX_TAG, JSON_HEX_AMP, JSON_HEX_APOS and JSON_HEX_QUOT, and JSON_THROW_ON_ERROR. htmlspecialchars() is wrong inside a script, and a raw </script> in the data would end the block early.

solid answer

~40 s

Inside `<script>` the browser does not decode HTML entities, so `htmlspecialchars()` only corrupts the data. What matters is that the HTML parser finds the end of the script by looking for `</script>`, before JavaScript runs: a review containing `</script><script>...` ends the block and starts a new one. `json_encode()` produces a valid JavaScript literal, and by default it already escapes `/`, so `</script>` becomes `<\/script>`. The `JSON_HEX_*` flags make the output independent of that default: `JSON_HEX_TAG` writes `<` and `>` as `\u003C` and `\u003E`, `JSON_HEX_AMP` writes `&` as `\u0026`, and `JSON_HEX_APOS` and `JSON_HEX_QUOT` write the quotes as `\u0027` and `\u0022`. Add `JSON_THROW_ON_ERROR`, because malformed UTF-8 otherwise makes `json_encode()` return `false`, which echoes as nothing. The alternative is to keep scripts static and pass data in a `data-*` attribute, JSON-encoded and then HTML-escaped.

code

php · 14 lines
php
<?php
declare(strict_types=1);

const JSON_FOR_HTML = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
    | JSON_THROW_ON_ERROR;

$reviews = [['author' => 'Ana', 'text' => 'Loved it </script><script>alert(1)</script>']];
?>
<script>
  const reviews = <?= json_encode($reviews, JSON_FOR_HTML) ?>;
  // text arrives as "Loved it \u003C\/script\u003E\u003Cscript\u003E..."
</script>

<div id="reviews" data-reviews="<?= htmlspecialchars(json_encode($reviews, JSON_FOR_HTML), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"></div>

go deeper

for a junior

Recall that data going into JavaScript is encoded with json_encode(), not htmlspecialchars(), and that inline scripts are a separate context.

for a middle

Explain how the HTML parser ends a script block, what each JSON_HEX_* flag converts, the default slash escaping, and the false return on invalid UTF-8.

for a senior

Show the data-attribute and JSON-block patterns, a shared flag constant for HTML embedding, and how these support a Content Security Policy without inline scripts.

for a principal

Decide how server data reaches client code across the product, favouring one reviewed mechanism over ad hoc inline interpolation in templates.

## Why a script block is its own context A page that shows hotel reviews may want the same reviews in JavaScript, for sorting or a map widget, and the tempting approach is: `<script>const reviews = <?= json_encode($reviews) ?>;</script>` Two parsers read this block, in order: 1. The **HTML parser** scans for the end of the script element. It does not understand JavaScript strings; it stops at the first `</script` it sees, and it treats `<!--` specially. 2. The **JavaScript parser** then runs whatever text sat between the tags. It does not decode HTML entities. So a safe payload must be valid JavaScript **and** must never contain a sequence the HTML parser treats as the end of the block. ## Why htmlspecialchars() is the wrong tool here Because the script body is not entity-decoded, `htmlspecialchars()` would turn a review's `Tom & Jerry's` into the literal JavaScript text `Tom &amp; Jerry&#039;s`. It also does nothing to make a value a valid JavaScript literal: `var s = '<?= e($x) ?>';` breaks on a backslash or a line break in the data. The encoder for this context is `json_encode()`, which produces a correctly quoted and escaped literal for any string, number, array or object. ## What json_encode() already does, and what the flags add | Character in the data | Default output | With the flag | |---|---|---| | `/` | `\/` (unless `JSON_UNESCAPED_SLASHES`) | unchanged | | `<`, `>` | unchanged | `\u003C`, `\u003E` with `JSON_HEX_TAG` | | `&` | unchanged | `\u0026` with `JSON_HEX_AMP` | | `'` | unchanged | `\u0027` with `JSON_HEX_APOS` | | `"` | `\"` | `\u0022` with `JSON_HEX_QUOT` | | U+2028, U+2029 | escaped | left raw only with `JSON_UNESCAPED_LINE_TERMINATORS` and `JSON_UNESCAPED_UNICODE` | By default the escaped slash already stops `</script>` from appearing. But the default is easy to lose: `JSON_UNESCAPED_SLASHES` is commonly added to make URLs in API output readable, and a shared encoding helper written for APIs then leaks into templates. With `JSON_HEX_TAG`, no `<` ever reaches the HTML parser, so neither `</script>` nor `<!--` can appear whatever other flags are set. The escapes are standard JSON, so `JSON.parse` and JavaScript literals decode them back to the original characters. The quote and ampersand flags matter when the same JSON may end up in an attribute or in a string-quoted context; they make the output safe to paste into more places without thought. ## Failure handling `json_encode()` returns `false` on failure, most often because the data contains invalid UTF-8, for example a review imported from a mis-encoded source. Echoing `false` prints nothing, so the page gets `const reviews = ;`, a syntax error. Two options: - `JSON_THROW_ON_ERROR` throws a `JsonException`, which surfaces the problem; - `JSON_INVALID_UTF8_SUBSTITUTE` replaces bad sequences with U+FFFD and keeps going. ## A safer structure: data attributes or a JSON block Keeping user data out of executable script is simpler to review: - **`data-*` attribute**: `<div id="reviews" data-reviews="<?= e(json_encode($reviews, JSON_THROW_ON_ERROR)) ?>">`. The JSON is HTML-escaped because it is now in an attribute; the script reads `dataset.reviews` and calls `JSON.parse`. - **A non-executed JSON block**: `<script type="application/json" id="reviews-data">` holding the `JSON_HEX_TAG` output, read with `JSON.parse(element.textContent)`. Both let you keep all JavaScript in static files, which also works with a strict Content Security Policy that forbids inline scripts. ## Checklist for code review When a template passes server data to JavaScript, a reviewer can check four things in order: 1. **Is the data inside executable script at all?** If it can move to a `data-*` attribute or a `type="application/json"` block, prefer that. 2. **Is it encoded with `json_encode()`**, not `htmlspecialchars()`, `addslashes()` or string concatenation? 3. **Are the flags the shared HTML-embedding set**, including `JSON_HEX_TAG`, rather than an API helper's flags that may include `JSON_UNESCAPED_SLASHES`? 4. **Is failure handled**, through `JSON_THROW_ON_ERROR` or a substitution flag, so bad input cannot produce an empty expression? A value that passes all four is safe in a script block; one that fails the first question is usually easier to move than to secure in place. ## Summary - Script context means `json_encode()`, never `htmlspecialchars()`. - Use `JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR` for anything embedded in HTML. - Prefer passing data through attributes or a JSON block over interpolating it into code.

  • If json_encode() already escapes slashes, why add JSON_HEX_TAG?
    The slash escaping disappears as soon as someone adds `JSON_UNESCAPED_SLASHES`, often in a shared helper written for API output. `JSON_HEX_TAG` removes every `<` and `>` regardless of other flags, so neither `</script>` nor `<!--` can appear. It costs nothing, since the escapes decode to the same characters in JavaScript.
  • What does the page receive if json_encode() fails on a review with invalid UTF-8?
    Without flags `json_encode()` returns `false`, which echoes as an empty string, leaving `const reviews = ;` and a script syntax error. `JSON_THROW_ON_ERROR` turns that into a `JsonException` you can log and handle, and `JSON_INVALID_UTF8_SUBSTITUTE` replaces the bad bytes instead.

saying these in an interview costs you the question

  • htmlspecialchars() is the right encoder inside a script block
  • Browsers decode HTML entities inside inline script code
  • json_encode() output is safe in HTML whatever flags are used
  • JSON_HEX_TAG changes the data JavaScript sees after parsing
  • json_encode() throws on invalid UTF-8 by default