
The default method is a block read with JSON.parse: the data is never interpreted as code, and the page stays compatible with a strict Content Security Policy. If you write JSON straight into a script, do it without quotes and without JSON.parse, using JSON_HEX_TAG and its neighbouring options.
Moving a value from PHP to JavaScript is an everyday need, and most of the methods you find online open an XSS hole. This article compares six approaches, shows what each one produces with a hostile value, and says which to use in which situation.
The root of the problem
PHP runs on the server, JavaScript in the browser. The only way through is the HTML that comes out. When a PHP value is written into JavaScript, it changes interpretation context: what was a string becomes code. Give it the right characters and it escapes the string and turns executable.
Take a realistic value, the kind a database would hand you:
$donnee = [
'nom' => "L'Écran </script><script>alert(1)</script>",
'note' => 4.5,
];It contains an apostrophe, a closing tag and an opening tag. Each one breaks a different method.
1. json_encode inside a script block
This is the most common method, and it is almost always written wrong.
// What not to do
<script>
let d = JSON.parse('<?php echo json_encode($donnee); ?>');
</script>What the browser receives:
let d = JSON.parse('{"nom":"L'Écran </script><script>alert(1)</script>","note":4.5}');Two things break. The apostrophe in “L’Écran” closes the JavaScript string: syntax error, the page stops running. And the </script> sequence closes the block at the level of the HTML parser, which knows nothing about JavaScript strings: the <script>alert(1)</script> that follows becomes a real script block.
The correct form has neither quotes nor JSON.parse(). JSON already is a valid JavaScript expression.
<script>
const donnees = <?= json_encode($donnee,
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE
) ?>;
</script>The result:
const donnees = {"nom":"L'Écran alert(1)","note":4.5};The four escaping options turn <, >, &, ' and " into uXXXX sequences. None of those characters appears literally any more, so none of them can close anything. JSON_UNESCAPED_UNICODE keeps accented characters readable, which lightens the output without giving anything up.
2. A separate JSON block
This is the method we recommend by default. The data leaves the code and becomes content.
<script type="application/json" id="donnees-page">
<?= json_encode($donnee, JSON_HEX_TAG | JSON_UNESCAPED_UNICODE) ?>
</script>const donnees = JSON.parse(
document.getElementById('donnees-page').textContent
);A <script> whose type the browser does not know is never executed: it is a plain text container. JSON_HEX_TAG is still needed to stop a </script> from closing the block, but the content is never interpreted as code.
This form has one decisive advantage: it works under a strict Content Security Policy. A site that bans inline scripts to protect itself against injection cannot use method 1 without adding a nonce or a hash.
3. Data attributes
For a handful of simple values attached to an element, the data-* attribute is the most natural answer.
<div id="produit"
data-id="<?= htmlspecialchars((string) $produit['id'], ENT_QUOTES, 'UTF-8') ?>"
data-prix="<?= htmlspecialchars((string) $produit['prix'], ENT_QUOTES, 'UTF-8') ?>">
</div>const el = document.getElementById('produit');
const id = Number(el.dataset.id);
const prix = parseFloat(el.dataset.prix);htmlspecialchars() with ENT_QUOTES is mandatory. Without it, a value containing a double quote escapes the attribute:
valeur brute : valeur" onfocus="alert(1)" autofocus x="
sans échappement : <input value="valeur" onfocus="alert(1)" autofocus x="">
avec échappement : <input value="valeur" onfocus="alert(1)" autofocus x="">dataset values are always strings: converting them to numbers is the JavaScript side’s job.
4. Hidden fields
An <input type="hidden"> works, with the same mandatory escaping.
<input type="hidden" id="jeton"
value="<?= htmlspecialchars($jeton, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">It is mostly useful when the value has to travel back to the server with the form. For a simple hand-off to JavaScript, the data-* attribute is cleaner: it does not pollute the form submission.
ENT_SUBSTITUTE is worth adding: without it, htmlspecialchars() returns an empty string when the value contains an invalid UTF-8 sequence. An empty field instead of a value is a hard bug to track down.
5. An API call
As soon as the data is large, changing, or depends on a user action, it has no business being in the HTML.
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
echo json_encode(
['produits' => $produits],
JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
);const reponse = await fetch('/api/produits.php', {
headers: { 'Accept': 'application/json' },
});
if (!reponse.ok) {
throw new Error(`HTTP ${reponse.status}`);
}
const { produits } = await reponse.json();Here HTML escaping is no longer a concern: the response is never interpreted as HTML, provided the Content-Type header is correct and comes with nosniff.
The cost is one extra request. For data needed on first paint, method 2 avoids that round trip.
6. Collecting several values
When several parts of the application each have a value to pass on, gathering them and writing them out in one go avoids scattering <script> blocks around. That is the principle behind Media::addJsDef() in PrestaShop.
<?php
declare(strict_types=1);
final class JsDefs
{
/** @var array<string, mixed> */
private array $valeurs = [];
public function ajouter(string $nom, mixed $valeur): void
{
$this->valeurs[$nom] = $valeur;
}
public function rendre(string $id = 'js-defs'): string
{
return sprintf(
'<script type="application/json" id="%s">%s</script>',
htmlspecialchars($id, ENT_QUOTES, 'UTF-8'),
json_encode($this->valeurs, JSON_HEX_TAG | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
);
}
}$defs = new JsDefs();
$defs->ajouter('urlPanier', '/panier');
$defs->ajouter('devise', '€');
$defs->ajouter('utilisateurConnecte', false);
echo $defs->rendre();const defs = JSON.parse(document.getElementById('js-defs').textContent);
console.log(defs.urlPanier, defs.devise, defs.utilisateurConnecte);Storing everything in an object rather than in a global variable avoids name collisions and makes the class testable.
Methods to leave alone
Two approaches keep doing the rounds and should not be used.
Going through the URL. Writing <a href="http://script.js?valeur=$v"> makes no sense: a JavaScript file is served as is, its URL parameters are never read. What does exist is reading the parameters of the current page on the client side, but then the value comes from the URL, not from PHP.
const page = new URLSearchParams(window.location.search).get('page');Going through a cookie. A cookie meant to be read by JavaScript cannot carry the HttpOnly attribute, which is the main protection against session theft. It is also sent back with every request, which weighs down all your traffic. A cookie is there to keep state between requests, not to hand a value to the current page.
A word about security
You often read that POST is “more secure” than GET. It is not. Both carry the data in the clear if the connection is not encrypted, and both can be altered by the user. The difference lies elsewhere: GET parameters appear in the URL, and therefore in browser history, in server logs and in the Referer header. It is a question of leaking through traces, not of transport security.
The point that holds for all six methods: anything that reaches the browser is visible to the user and can be changed by them. A price, a role id or a total handed to JavaScript has to be revalidated on the server at every action. No data sent to the client is trustworthy on the way back.
Which one to choose
| Need | Method |
|---|---|
| A configuration object at page load | application/json block |
| A few values tied to one element | data-* attributes |
| A value that travels back with a form | Hidden field |
| Large or late-arriving data | API call |
| Several modules contributing values | An accumulator, rendered in one pass |
| Strict Content Security Policy | application/json block, never an inline script |
If the data comes from a third-party API, a cURL request is what fetches it server-side. The accumulator shown above applies the principles of OOP in PHP, and redirecting after processing is covered in creating a redirect in PHP.
See also json_encode and PHP object serialisation, and the Web development hub.
Common errors
</script> sequence closes the block at the HTML parser level. Result: a syntax error and a script injection.</script> appears literally and escapes the block, including inside a <script type="application/json">.htmlspecialchars() returns an empty string instead of the value.HttpOnly, and it is sent back with every request.

