HTML, attributes, JavaScript, URLs, and CSS each need their own rules
Relying on a single universal escaping function puts Magento and Hyvä stores at risk, because HTML, attributes, JavaScript, URLs, and CSS each follow different syntax rules and attack vectors. This article explains why htmlspecialchars alone is not enough, how the Hyvä Escaper class matches the right context, and how Content Security Policy acts as an additional line of defense.
Table of Contents
- 1. Why one universal escaping function is wrong
- 2. The five output contexts: HTML, attribute, JavaScript, URL, CSS
- 3. htmlspecialchars(): the classic pitfalls
- 4. Contextual auto-escaping in templating engines
- 5. The Magento/Hyvä Escaper class in detail
- 6. Double encoding: when escaping is applied too often
- 7. DOM-based XSS: innerHTML vs. textContent
- 8. Content Security Policy as an additional line of defense
- 9. Escaping in practice: a code review checklist
- 10. Summary
- 11. FAQ
1. Why one universal escaping function is wrong
Many developers treat XSS protection as a checkbox: every dynamic output gets piped through a single function, usually htmlspecialchars(), and the topic is considered handled. That mindset ignores that escaping is not a single algorithm, it depends on the syntax of the destination context. A value that is safe inside HTML body text can still enable arbitrary code execution inside a JavaScript string, a URL, or a CSS value, because entirely different characters are dangerous there.
The term output encoding describes exactly this context-specific transformation: control characters of the target format are encoded so the parser reads them as data instead of code. HTML has &, <, and >, attributes add quote characters, JavaScript strings add backslashes and quotes, URLs have reserved characters per RFC 3986, and CSS has control characters via backslash-hex sequences. Each of these rule sets is different, and some are even mutually contradictory.
For a code review this means the question is never just whether a value is escaped, but whether it is escaped with the right method for that exact output location. The following sections walk through the five most important contexts, show typical htmlspecialchars() mistakes, introduce the Hyvä Escaper class, and place Content Security Policy as an additional, but never sole, line of defense.
2. The five output contexts: HTML, attribute, JavaScript, URL, CSS
HTML body text is the simplest context: only &, <, and > need encoding, because they introduce markup. Once a value sits inside an attribute, quote characters join the list, because an unescaped " or ' ends the attribute early, and everything after it is parsed as a new attribute or markup, even if the value contains no < at all.
A JavaScript string context carries a completely different set of dangerous characters: backslashes, quotes, line breaks, and the character sequence that closes a surrounding inline script block all need JS-specific encoding. HTML entities do not help here, because the JavaScript parser never resolves them, it just treats them as literal text. URLs, in turn, follow their own rules under RFC 3986: reserved characters like &, ?, or # get percent-encoded, and the scheme must additionally be validated, because javascript: as a pseudo-protocol can execute code with no script tag in the markup at all.
CSS values finally need backslash-hex escaping, because expression() in older browsers and url() values with embedded JavaScript have historically been attack vectors. Anyone who fails to keep these five contexts cleanly separated will inevitably apply the wrong encoding somewhere, usually without a functional test noticing, because the code appears to work correctly at first glance.
3. htmlspecialchars(): the classic pitfalls
htmlspecialchars() is PHP's default function for HTML escaping, but its defaults change between PHP versions and are routinely assumed incorrectly. Before PHP 8.1, the function encoded only double quotes without an explicit flag, leaving single quotes untouched. An attribute written with single quotes could therefore still be broken out of despite escaping. Since PHP 8.1, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 is the default, but you should never rely on that, because codebases rarely stay on a single PHP version.
The second problem is the missing charset argument. Without an explicit 'UTF-8', htmlspecialchars() interprets the string differently depending on PHP configuration, which leads to inconsistent or broken escaping with special characters, for example when multi-byte characters get split mid-character. The correct signature is therefore always htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').
The third and most common mistake is the context itself: htmlspecialchars() encodes exclusively for HTML. If the return value is embedded in a JavaScript string, a URL, or a CSS value, the actual injection gap stays wide open, because entirely different characters are dangerous there. The example below shows both mistake classes and the correct alternative.
<?php
declare(strict_types=1);
// WRONG: no ENT_QUOTES, no explicit charset - single-quoted attributes stay unescaped
$name = "O'Brien\" onmouseover=\"alert(1)";
echo '<input type="text" value="' . htmlspecialchars($name) . '">';
// Rendered: <input type="text" value="O'Brien" onmouseover="alert(1)">
// The single quote around O'Brien breaks out of the double-quoted HTML
// attribute in older default flag combinations - never rely on defaults.
// WRONG: HTML-escaping a value does not make it safe for a JS string context
$search = '"; alert(document.cookie); //';
$jsUnsafe = htmlspecialchars($search);
// Embedding $jsUnsafe inside an inline JS string still allows the payload
// to break out, because HTML entities are meaningless inside JavaScript.
// RIGHT: explicit flags and charset for a genuine HTML attribute
$safeAttr = htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
echo '<input type="text" value="' . $safeAttr . '">';
// RIGHT: for a JS string context, encode for JS - json_encode with hex flags
$safeJs = json_encode($search, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
// $safeJs can now be embedded directly after "var q = " in an inline script block
4. Contextual auto-escaping in templating engines
Modern templating engines like Twig, Blade, or Latte implement context-aware auto-escaping: the compiler detects, while parsing the template, whether a variable ends up in HTML body text, an attribute, or an inline script block, and automatically applies the matching encoder. In these systems, developers have to actively opt out of protection, for example with a |raw filter, to produce unescaped output.
Plain PHP in .phtml files has no automatic escaping, because <?= ?> simply outputs text without analyzing the surrounding context. Classic Magento themes before Hyvä often skipped escaping entirely for exactly this reason and relied on block methods that rarely escaped consistently. Hyvä makes the manual, but explicit, context choice mandatory and enforces it through coding standard sniffs that flag unescaped output right in code review.
Auto-escaping is not a free pass: even context-sensitive engines do not reliably detect every nesting, for example a variable inside an inline event handler attribute that itself contains JavaScript. Blindly trusting the engine instead of consciously checking the destination context merely shifts the risk from PHP to the template language.
5. The Magento/Hyvä Escaper class in detail
Magento provides a central class, Magento\Framework\Escaper, which is injected into Hyvä templates as $escaper and offers a dedicated method for every context. escapeHtml() encodes for HTML body text and optionally accepts a list of allowed tags when limited markup like <strong> or <em> should pass through. escapeHtmlAttr() additionally encodes quote characters and is mandatory for every attribute value, whether href, alt, title, or a data attribute.
escapeJs() encodes for simple JavaScript string contexts, such as inside an inline event handler attribute like onclick. For structured data like arrays or objects, json_encode() with the flags JSON_HEX_TAG, JSON_HEX_APOS, JSON_HEX_QUOT, and JSON_HEX_AMP is the more robust choice, because escapeJs() does not guarantee valid JSON syntax. escapeUrl() validates a link's scheme and encodes reserved characters, escapeXssInUrl() goes a step further and specifically strips XSS vectors like javascript: from user-controlled redirect targets. escapeCss(), finally, encodes values that get embedded in style attributes or CSS blocks.
The rule for choosing between them is simple: determine the destination context first, then pick the matching method. A product name in a heading needs escapeHtml(), the same name in the alt attribute needs escapeHtmlAttr(), the same SKU inside an onclick handler needs escapeJs(). The example below shows all six methods in a realistic product template.
<?php
/** @var \Magento\Framework\Escaper $escaper */
/** @var \Magento\Catalog\Block\Product\View $block */
$product = $block->getProduct();
?>
<!-- HTML body context: product name rendered as text -->
<h1 class="text-2xl font-bold"><?= $escaper->escapeHtml($product->getName()) ?></h1>
<!-- HTML attribute context: alt/title need quote-safe encoding -->
<img src="<?= $escaper->escapeUrl($block->getImageUrl()) ?>"
alt="<?= $escaper->escapeHtmlAttr($product->getName()) ?>"
title="<?= $escaper->escapeHtmlAttr($product->getMetaTitle()) ?>">
<!-- JS string context inside an inline event handler attribute -->
<button
type="button"
onclick="trackProductView('<?= $escaper->escapeJs($product->getSku()) ?>')"
class="rounded-lg bg-purple-700 px-4 py-2 text-white">
Track view
</button>
<!-- Structured data passed to Alpine: json_encode + hex flags, not escapeHtml -->
<div x-data='<?= /* @noEscape */ json_encode([
'id' => $product->getId(),
'sku' => $product->getSku(),
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>'>
</div>
<!-- URL context: href is scheme-checked, not just percent-encoded -->
<a href="<?= $escaper->escapeUrl($block->getBackUrl()) ?>">Back</a>
<!-- User-controlled redirect target: strip javascript: and similar vectors -->
<a href="<?= $escaper->escapeXssInUrl($block->getReturnUrl()) ?>">Continue</a>
<!-- CSS context: dynamic value inside an inline style attribute -->
<div style="background-image: url('<?= $escaper->escapeCss($block->getBannerUrl()) ?>');"></div>
6. Double encoding: when escaping is applied too often
Double encoding happens when a value has already been escaped in one place and then runs through the same or a different escaping function a second time. & turns into &amp;, ' turns into &#039;, visibly broken special characters for every user on the page. The most common cause: a ViewModel or block method already escapes the value "to be safe," and the template escapes it again on output, because nobody is quite sure anymore whether the value is already safe.
The problem gets worse in URL contexts, where %26 turns into %2526 through repeated encoding, silently breaking redirect comparisons, signature checks, or OAuth callbacks without any obvious error message. The reliable rule is: raw data gets stored and passed around in the domain layer, and escaping happens exclusively at the very last point before output, where the destination context is unambiguously known.
In practice this means ViewModel and repository methods always return unescaped values, and only the .phtml template, which knows the concrete HTML, attribute, or JS context, calls the matching Escaper method. This clear separation of responsibility prevents both missing and double escaping alike.
<?php
declare(strict_types=1);
// WRONG: escaping happens twice - once in the ViewModel, once in the template.
// Step 1: ViewModel already HTML-escapes the value.
final class ProductViewModel
{
public function getFormattedName(): string
{
return htmlspecialchars($this->product->getName(), ENT_QUOTES, 'UTF-8');
}
}
// Step 2: the phtml template escapes the (already escaped) value again:
// <h1><?= $escaper->escapeHtml($viewModel->getFormattedName()) ?></h1>
//
// Input: M&M's Chocolate
// Step 1: M&M's Chocolate
// Step 2: M&amp;M&#039;s Chocolate <- visibly broken on the page
// RIGHT: escape exactly once, as late as possible, at the final output boundary.
final class ProductViewModel
{
// Return raw domain data - no escaping in the ViewModel layer.
public function getName(): string
{
return $this->product->getName();
}
}
// The template is the only place that knows the destination context:
// <h1><?= $escaper->escapeHtml($viewModel->getName()) ?></h1>
7. DOM-based XSS: innerHTML vs. textContent
Server-side output encoding does not protect against DOM-based XSS, because this attack type happens entirely inside the browser, without the malicious value ever passing through the server. When a value gets inserted into the DOM via innerHTML or insertAdjacentHTML(), the browser parses it as HTML markup, including embedded error-handler attributes like onerror, which execute immediately.
The safe alternative is textContent (or innerText), because both properties set the value strictly as text, regardless of which characters it contains, with no HTML parsing at all. If HTML markup genuinely needs to be inserted dynamically, there is no way around a real sanitizing library like DOMPurify, plain escaping is not enough here, because allowed markup is precisely what must not be encoded.
The same rule applies in Hyvä templates for Alpine.js: x-text is the safe equivalent of textContent and should be the default choice. x-html behaves like innerHTML and must only be used for fully static, hard-coded content, never for values that come from user input, query parameters, or external APIs.
// WRONG: DOM-based XSS - innerHTML parses the string as HTML
function renderSearchSuggestion(term) {
const el = document.querySelector('#suggestion');
el.innerHTML = term; // if term contains an img tag with an onerror handler, it executes
}
// WRONG: insertAdjacentHTML has the exact same problem
function appendNotice(message) {
document.querySelector('#notices')
.insertAdjacentHTML('beforeend', '<div class="notice">' + message + '</div>');
}
// RIGHT: textContent never parses markup, it is always safe for plain text
function renderSearchSuggestion(term) {
const el = document.querySelector('#suggestion');
el.textContent = term;
}
// RIGHT: build elements via the DOM API instead of concatenating HTML strings
function appendNotice(message) {
const div = document.createElement('div');
div.className = 'notice';
div.textContent = message;
document.querySelector('#notices').appendChild(div);
}
// Alpine.js: x-text is the safe equivalent of textContent, x-html behaves like innerHTML
// <div x-text="suggestion"></div> -- safe
// <div x-html="suggestion"></div> -- only for fully trusted, static markup
8. Content Security Policy as an additional line of defense
A Content Security Policy (CSP) restricts which script sources a browser is allowed to execute at all, and blocks inline scripts by default unless a matching nonce attribute is present. That means CSP catches exactly the cases where output encoding has failed: even if an attacker successfully injects unescaped code, a strict policy without 'unsafe-inline' prevents the browser from actually executing it.
The order of defense matters here: CSP is an additional layer on top of correct output encoding, never a replacement for it. A policy with 'unsafe-inline' or with wildcard hosts in script-src offers virtually no protection anymore and only creates a false sense of security. Magento's Hyvä CSP module automatically generates a nonce per request, which must be registered via $hyvaCsp->registerInlineScript() after every inline script block, otherwise the browser silently blocks it.
Using CSP correctly gives you a line of defense that still holds even when an escaping mistake gets overlooked somewhere in the code, for example in a newly added third-party integration. But CSP cannot and should not replace actual output encoding, because it does not address server-side logic flaws like SQL injection or broken redirects at all.
<?xml version="1.0"?>
<!-- etc/csp_whitelist.xml: explicit allow-list instead of 'unsafe-inline' -->
<csp_whitelist xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Csp:etc/csp_whitelist.xsd">
<policies>
<policy id="script-src">
<values>
<value id="mironsoft-analytics" type="host">https://analytics.mironsoft.de</value>
</values>
</policy>
<policy id="style-src">
<values>
<value id="google-fonts" type="host">https://fonts.googleapis.com</value>
</values>
</policy>
</policies>
</csp_whitelist>
<!-- No 'unsafe-inline' in script-src: every inline block needs a per-request nonce. -->
<!-- In the phtml, call $hyvaCsp->registerInlineScript() right after each inline block -->
<!-- so the framework can inject the matching nonce attribute automatically. -->
9. Escaping in practice: a code review checklist
A systematic code review benefits from a fixed order: first identify every output point and determine the exact destination context, HTML body, attribute, JS string, URL, or CSS. Then check whether the method designed for that context is actually used, rather than a generic function that happened to be written for a different context.
Next, check for double encoding, especially at the boundary between ViewModel and template, and search all DOM manipulations in JavaScript for innerHTML or insertAdjacentHTML() calls that could be replaced with textContent or x-text. Magento's coding standard sniff for templates (Magento2.Security.XssTemplate) catches many of these cases statically, before the code is even merged.
In the end, a quick payload test beats any amount of theory: insert values like <script>alert(1)</script>, "onmouseover="alert(1), or javascript:alert(1) into every identified context and check whether the payload shows up as plain text in the rendered HTML or actually executes. This check takes minutes and reliably reveals where a wrong or missing context mapping was overlooked.
| Output context | Insecure / wrong context | Correct Hyvä Escaper method | Risk when misapplied |
|---|---|---|---|
| HTML body text | echo $value with no escaping |
$escaper->escapeHtml($value) |
Stored/reflected XSS via inline scripts |
| HTML attribute | htmlspecialchars($value) without ENT_QUOTES |
$escaper->escapeHtmlAttr($value) |
Attribute breakout via a single quote |
| JavaScript string | HTML escaping inside an inline script block | $escaper->escapeJs($value) / json_encode() |
Script injection via quotes or backslash |
| URL / href | rawurlencode() with no scheme check |
$escaper->escapeUrl() / escapeXssInUrl() |
javascript: pseudo-protocol as an XSS vector |
| CSS / style | direct value embedding into style="" |
$escaper->escapeCss($value) |
CSS injection and data exfiltration via url() |
Mironsoft
Security audits, XSS prevention, and Hyvä hardening for Magento stores
Are your templates actually protected against XSS?
We review your Magento and Hyvä templates for missing, wrong, and double escaping, harden your CSP configuration, and close DOM-based XSS gaps in Alpine.js components before an attacker finds them.
Escaping audit
Static analysis and manual payload testing per output context
Escaper refactoring
Consistently retrofitting escapeHtml, escapeHtmlAttr, escapeJs, and escapeUrl
CSP hardening
Setting up a nonce-based Content Security Policy without unsafe-inline
10. Summary
The core insight of output encoding is that escaping is not a universal switch, each output context needs its own rule. HTML body text, HTML attributes, JavaScript strings, URLs, and CSS values each have different dangerous characters and therefore different correct encoding functions. htmlspecialchars() without ENT_QUOTES, without an explicit charset, or applied in the wrong context leaves exactly the gaps an attacker is looking for.
The Magento/Hyvä Escaper class maps these contexts with six clearly named methods: escapeHtml(), escapeHtmlAttr(), escapeJs(), escapeUrl(), escapeCss(), and escapeXssInUrl(). Consistently determining the context first and then choosing the matching method avoids both missing and double escaping. DOM-based XSS via innerHTML can be reliably prevented with textContent or Alpine's x-text, and a nonce-based Content Security Policy adds correct encoding as a final, but never sole, line of defense.
Output Encoding and Escaping: The Essentials at a Glance
Context first
Determine HTML body, attribute, JS string, URL, or CSS before even choosing an escaping function.
Use htmlspecialchars() correctly
Always pass ENT_QUOTES | ENT_SUBSTITUTE and 'UTF-8' explicitly, never rely on defaults.
Use the Hyvä Escaper
Apply escapeHtml, escapeHtmlAttr, escapeJs, escapeUrl, escapeCss, and escapeXssInUrl based on the destination context.
CSP as a second line of defense
A nonce-based Content Security Policy adds to, but never replaces, correct output encoding.