
De standaardmethode is een blok dat je met JSON.parse uitleest: de data wordt nooit als code geïnterpreteerd en de pagina blijft compatibel met een strikt Content Security Policy. Schrijf je JSON rechtstreeks in een script, doe dat dan zonder aanhalingstekens en zonder JSON.parse, met JSON_HEX_TAG en de verwante opties.
Een waarde van PHP naar JavaScript doorgeven is dagelijkse kost, en de meeste methoden die je online vindt, openen een XSS-lek. Dit artikel vergelijkt zes aanpakken, laat zien wat elke aanpak met vijandige data oplevert en geeft aan welke je per geval gebruikt.
De kern van het probleem
PHP draait op de server, JavaScript in de browser. Het enige doorgeefluik is de HTML die eruit komt. Zodra PHP-data in JavaScript wordt geschreven, verandert de interpretatiecontext: wat een string was, wordt code. Bevat de data de juiste tekens, dan breekt ze uit die string en wordt ze uitvoerbaar.
Neem een realistische waarde, zoals ze uit een database zou komen:
$donnee = [
'nom' => "L'Écran </script><script>alert(1)</script>",
'note' => 4.5,
];Ze bevat een apostrof, een sluitende tag en een openende tag. Elk daarvan breekt een andere methode.
1. json_encode in een scriptblok
Dit is de bekendste methode, en ze wordt bijna altijd fout geschreven.
// Zo moet het niet
<script>
let d = JSON.parse('<?php echo json_encode($donnee); ?>');
</script>Wat de browser te zien krijgt:
let d = JSON.parse('{"nom":"L'Écran </script><script>alert(1)</script>","note":4.5}');Twee breuken. De apostrof van “L’Écran” sluit de JavaScript-string: syntaxisfout, de pagina draait niet meer. En de reeks </script> sluit het blok op het niveau van de HTML-parser, die niets van JavaScript-strings weet: de <script>alert(1)</script> die volgt, wordt een echt scriptblok.
De juiste vorm heeft geen aanhalingstekens en geen JSON.parse(). JSON is al een geldige JavaScript-expressie.
<script>
const donnees = <?= json_encode($donnee,
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE
) ?>;
</script>Resultaat:
const donnees = {"nom":"L'Écran alert(1)","note":4.5};De vier escape-opties zetten <, >, &, ' en " om in uXXXX-reeksen. Geen van die tekens komt nog letterlijk voor, dus geen ervan kan nog iets afsluiten. JSON_UNESCAPED_UNICODE houdt de accenten leesbaar, wat de uitvoer lichter maakt zonder iets prijs te geven.
2. Een apart JSON-blok
Dit is de methode die we standaard aanraden. De data verlaat de code en wordt 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
);Een <script> met een type dat de browser niet kent, wordt niet uitgevoerd: het is niet meer dan een tekstcontainer. JSON_HEX_TAG blijft nodig om te verhinderen dat een </script> het blok afsluit, maar de inhoud wordt nooit als code geïnterpreteerd.
Deze vorm heeft een doorslaggevend voordeel: ze werkt onder een strikt Content Security Policy. Een site die inline scripts verbiedt om zich tegen injecties te beschermen, kan methode 1 niet gebruiken zonder er een nonce of een hash aan toe te voegen.
3. De data-attributen
Voor een paar eenvoudige waarden die aan een element hangen, is het attribuut data-* de meest natuurlijke oplossing.
<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() met ENT_QUOTES is verplicht. Zonder die optie breekt een waarde met een aanhalingsteken uit het attribuut:
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="">De waarden uit dataset zijn altijd strings: de omzetting naar een getal doe je aan de JavaScript-kant.
4. Verborgen velden
Een <input type="hidden"> werkt, met dezelfde verplichte escaping.
<input type="hidden" id="jeton"
value="<?= htmlspecialchars($jeton, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">Dat is vooral nuttig wanneer de waarde met het formulier weer naar de server moet. Voor een simpele doorgifte naar JavaScript is het attribuut data-* netter: het vervuilt de formulierverzending niet.
ENT_SUBSTITUTE is het toevoegen waard: zonder die optie geeft htmlspecialchars() een lege string terug zodra de data een ongeldige UTF-8-reeks bevat. Een leeg veld in plaats van een waarde is een bug die je maar moeilijk terugvindt.
5. Een API-aanroep
Zodra de data omvangrijk is, vaak verandert of van een actie van de gebruiker afhangt, heeft ze niets in de HTML te zoeken.
<?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();In dat geval speelt HTML-escaping geen rol meer: het antwoord wordt nooit als HTML geïnterpreteerd, op voorwaarde dat de header Content-Type klopt en vergezeld gaat van nosniff.
De prijs is een extra request. Voor data die je al bij de eerste weergave nodig hebt, vermijdt methode 2 dat heen en weer.
6. Meerdere waarden verzamelen
Wanneer verschillende delen van de applicatie elk een waarde door te geven hebben, voorkomt ze verzamelen en in één keer wegschrijven dat je <script>-blokken over de pagina uitsmeert. Dat is het principe van 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);Opslaan in een object in plaats van in een globale variabele voorkomt naamconflicten en maakt de klasse testbaar.
De methoden die je links laat liggen
Twee aanpakken circuleren en horen niet gebruikt te worden.
Via de URL. <a href="http://script.js?valeur=$v"> schrijven slaat nergens op: een JavaScript-bestand wordt geserveerd zoals het is, zijn URL-parameters worden niet gelezen. Wat wel bestaat, is de parameters van de huidige pagina aan de clientkant uitlezen, maar dan komt de waarde uit de URL en niet uit PHP.
const page = new URLSearchParams(window.location.search).get('page');Via een cookie. Een cookie dat door JavaScript gelezen moet worden, kan het attribuut HttpOnly niet dragen, en dat is de belangrijkste bescherming tegen sessiediefstal. Het gaat bovendien bij elke request weer mee, wat al het verkeer verzwaart. Een cookie dient om een status tussen twee requests te bewaren, niet om data door te geven binnen de huidige pagina.
Een verduidelijking over beveiliging
Je leest vaak dat POST “veiliger” zou zijn dan GET. Dat klopt niet. Beide vervoeren de data leesbaar als de verbinding niet versleuteld is, en beide zijn door de gebruiker aan te passen. Het verschil zit elders: GET-parameters staan in de URL, en dus in de browsergeschiedenis, in de serverlogs en in de header Referer. Het gaat om lekken via sporen, niet om de beveiliging van het transport.
Wat voor alle zes de methoden geldt: alles wat in de browser aankomt, is voor de gebruiker zichtbaar en aanpasbaar. Een prijs, een rol-id of een totaal dat je naar JavaScript stuurt, moet bij elke actie opnieuw aan de serverkant worden gevalideerd. Geen enkele waarde die je naar de client stuurt, is bij terugkeer te vertrouwen.
Wat kies je
| Behoefte | Methode |
|---|---|
| Een configuratieobject bij het laden | Blok application/json |
| Een paar waarden die bij een element horen | Attributen data-* |
| Een waarde die met een formulier terugkeert | Verborgen veld |
| Omvangrijke of laat opgehaalde data | Een API-aanroep |
| Meerdere modules die elk bijdragen | Een accumulator, in één keer gerenderd |
| Strikt Content Security Policy | Blok application/json, nooit een inline script |
Komt de data van een externe API, dan haalt een cURL-request ze aan de serverkant op. De accumulator hierboven past de principes van OOP in PHP toe, en de redirect na verwerking staat uitgewerkt in een redirect maken in PHP.
Zie ook json_encode en de serialisatie van PHP-objecten, en de hub Webontwikkeling.
Veelgemaakte fouten
</script> sluit het blok op het niveau van de HTML-parser. Resultaat: syntaxisfout en scriptinjectie.</script> letterlijk en breekt het uit het blok, ook in een <script type="application/json">.htmlspecialchars() een lege string terug in plaats van de waarde.HttpOnly dragen, en het gaat bij elke request weer mee.

