Passing variables from PHP to JavaScript: 6 methods

Passing variables from PHP to JavaScript: 6 methods
Quick answer

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:

php
$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.

php
// What not to do
<script>
  let d = JSON.parse('<?php echo json_encode($donnee); ?>');
</script>

What the browser receives:

javascript
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.

php
<script>
  const donnees = <?= json_encode($donnee,
      JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE
  ) ?>;
</script>

The result:

javascript
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.

php
<script type="application/json" id="donnees-page">
  <?= json_encode($donnee, JSON_HEX_TAG | JSON_UNESCAPED_UNICODE) ?>
</script>
javascript
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.

php
<div id="produit"
     data-id="<?= htmlspecialchars((string) $produit['id'], ENT_QUOTES, 'UTF-8') ?>"
     data-prix="<?= htmlspecialchars((string) $produit['prix'], ENT_QUOTES, 'UTF-8') ?>">
</div>
javascript
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:

code
valeur brute : valeur" onfocus="alert(1)" autofocus x="
sans échappement : <input value="valeur" onfocus="alert(1)" autofocus x="">
avec échappement : <input value="valeur&quot; onfocus=&quot;alert(1)&quot; autofocus x=&quot;">

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.

php
<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.

api/produits.php
<?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,
);
javascript
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.

src/JsDefs.php
<?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),
        );
    }
}
php
$defs = new JsDefs();
$defs->ajouter('urlPanier', '/panier');
$defs->ajouter('devise', '€');
$defs->ajouter('utilisateurConnecte', false);

echo $defs->rendre();
javascript
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.

javascript
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

JSON.parse('<?= json_encode($x) ?>') An apostrophe in the value closes the JavaScript string, and a </script> sequence closes the block at the HTML parser level. Result: a syntax error and a script injection.
JSON_HEX_TAG left out Without it, </script> appears literally and escapes the block, including inside a <script type="application/json">.
Attribute written without htmlspecialchars A value containing a double quote escapes the attribute and adds an event handler.
ENT_SUBSTITUTE missing On an invalid UTF-8 sequence, htmlspecialchars() returns an empty string instead of the value.
POST presented as safer than GET Wrong. Both travel in the clear without TLS. The difference is in the traces: the URL shows up in history, in logs and in the Referer.
Cookie used to hand a value to the page A cookie readable by JavaScript cannot carry HttpOnly, and it is sent back with every request.
Client-side data treated as trustworthy A price or a role id handed to JavaScript can be changed. Any value that comes back must be revalidated on the server.

JavaScriptJSONPHPSécuritéXSS

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.