Blog / Coding tips

PHP htmlspecialchars to Prevent XSS (Tested Examples)

To prevent cross-site scripting (XSS) when you print user data in HTML with PHP, escape it on output with htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'). That turns <, >, &, " and ' into entities so a string like <script>alert(1)</script> becomes harmless text instead of a real script tag. Escape when you echo into HTML, not when you save to the database. Prepared statements stop SQL injection; htmlspecialchars stops HTML injection. They solve different problems.

I wrote this as the output half of my PHP form validation and sanitisation note. Validation decides whether data is allowed in; escaping decides how it is shown. Every snippet below was run on PHP 8.4 on the command line, and the outputs are pasted from that run. If you only remember one habit from this page, make it: every user-controlled string that becomes HTML goes through htmlspecialchars (or a tiny e() helper) at the last moment.

What XSS looks like in a PHP page

Cross-site scripting means an attacker gets the browser to treat their text as code on your origin. The classic PHP bug is reflecting a query string straight into the page:

<?php
// Vulnerable on purpose — do not ship this
$q = $_GET['q'] ?? '';
echo '<p>You searched for: ' . $q . '</p>';

Visit ?q=<script>alert(1)</script> and the browser runs the script with your cookies and session. Stored XSS is the same idea with a delay: the payload is saved in the database (a comment, a display name) and runs when someone else views the page. Reflected XSS hits the person who opens the crafted link; stored XSS hits every visitor who sees that row.

Neither bug is fixed by “validating that the search is not empty”. Empty checks and length limits are still useful, but the attack string is usually short and looks like normal text until it is parsed as HTML. Escaping on output is the reliable fix for HTML contexts.

The basic htmlspecialchars example (tested)

<?php
declare(strict_types=1);

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

$safe = htmlspecialchars($q, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
echo $safe, PHP_EOL;

$name = 'O\'Brien "quoted" <b>x</b>';
$attr = htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
echo '<input value="' . $attr . '">', PHP_EOL;

// PHP 8.1+ defaults already include ENT_QUOTES | ENT_SUBSTITUTE
echo htmlspecialchars("a\"b'c<d>&"), PHP_EOL;

$price = '£12.50';
echo htmlspecialchars($price, ENT_QUOTES, 'UTF-8'), PHP_EOL;

Output from PHP 8.4:

&lt;script&gt;alert(1)&lt;/script&gt;
<input value="O&#039;Brien &quot;quoted&quot; &lt;b&gt;x&lt;/b&gt;">
a&quot;b&#039;c&lt;d&gt;&amp;
£12.50

Reading that output:

  • The script tags are now text entities. The browser will show <script>... on the page instead of executing it.
  • Inside the attribute, both " and ' are encoded, so neither style of quoting lets the value break out.
  • The pound sign stays as £ when you pass 'UTF-8'. That is one reason I prefer htmlspecialchars over htmlentities on modern UTF-8 sites.

Why ENT_QUOTES and UTF-8 matter

Older tutorials often write htmlspecialchars($s) without flags. Before PHP 8.1 the default was ENT_COMPAT, which encodes double quotes but leaves single quotes alone. This markup is then still vulnerable:

<input value='<?= htmlspecialchars($name) ?>'>

An input of x' onfocus='alert(1) can break out. ENT_QUOTES encodes both quote styles. ENT_SUBSTITUTE avoids the nasty case where invalid UTF-8 made older PHP return an empty string and silently blank the field — attackers used encoding tricks historically; substitution is the safer modern default.

Always pass 'UTF-8' when you set flags yourself. If the charset argument is wrong, multibyte characters can be mishandled. Your HTML page should also declare UTF-8 (Content-Type header and/or <meta charset="utf-8">) so the browser agrees with PHP.

A reusable e() helper

<?php
function e(?string $s): string
{
    return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

Usage in a template:

<p>Hello, <?= e($user['name']) ?></p>
<input name="email" value="<?= e($email) ?>">
<textarea name="bio"><?= e($bio) ?></textarea>

Casting null to string keeps strict types happy and prints nothing. Name the helper something short so you actually use it. In larger apps the same idea lives in the template engine (Twig and Blade auto-escape in their default print syntax). In plain PHP you are the template engine, so the helper is on you.

Do not treat e() as “safe for every context”. It is for HTML text and quoted attributes only. JavaScript strings, CSS, and raw URLs need different handling (covered below).

Sticky forms: the attribute trap

After validation fails you redisplay the form. That is good UX and a common XSS hole, because the value sits inside quotes:

<?php
function e(?string $s): string
{
    return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

$email = $_POST['email'] ?? 'test" onclick="alert(1)';
echo '<input name="email" value="' . e($email) . '">', PHP_EOL;

Output:

<input name="email" value="test&quot; onclick=&quot;alert(1)">

Without e(), the browser would see a closed value and a new onclick attribute. With e(), the whole attack stays inside the value string.

This pairs with:

Sanitising on the way in (trim, length, email format) is still worthwhile. It is not a substitute for escaping on the way out.

Escape on output, not on insert

A pattern I still see in old WordPress-era snippets:

<?php
// Avoid this pattern
$clean = htmlspecialchars($_POST['title'], ENT_QUOTES, 'UTF-8');
$pdo->prepare('INSERT INTO posts (title) VALUES (?)')->execute([$clean]);

Problems:

  1. The database now holds entities. APIs, CSV exports and plain-text emails will show &amp; litter.
  2. Someone later escapes again in the template → &amp;amp;.
  3. You still need prepared statements for SQL safety; encoding HTML does nothing for SQL.

Prefer:

<?php
$title = trim($_POST['title'] ?? '');
// validate length / allowed characters as needed
$pdo->prepare('INSERT INTO posts (title) VALUES (?)')->execute([$title]);

// Much later, in the view:
echo '<h2>' . e($post['title']) . '</h2>';

Database safety is PDO prepared statements. HTML safety is htmlspecialchars. Password safety is password_hash. Three tools, three jobs.

When htmlspecialchars is not enough

Free-form URLs

<?php
$url = 'javascript:alert(1)';
echo htmlspecialchars($url, ENT_QUOTES, 'UTF-8'), PHP_EOL;
// Still prints: javascript:alert(1)

Put that in href and you may still get script execution. Escape does not validate schemes. Allow only http and https after parse_url, or map an ID to a known path on your site.

Inline JavaScript and event handlers

<!-- Dangerous pattern -->
<button onclick="greet('<?= e($name) ?>')">Hi</button>

Even with HTML escaping, quote tricks and context confusion bite people here. Prefer:

<button id="greet" data-name="<?= e($name) ?>">Hi</button>
<script>
document.getElementById('greet').addEventListener('click', (e) => {
  const name = e.currentTarget.dataset.name;
  // use name as data, not as code
});
</script>

Or pass structured data with json_encode into a type="application/json" script tag, then read it from JavaScript.

CSS and style attributes

Do not interpolate user input into style="...". Colour pickers should map to an allow-list of classes or hex values you validate with a regex, not raw strings.

“I want users to post HTML”

Blog comments with bold/links need an HTML purifier with a strict allow-list of tags and attributes. strip_tags($html, '<b><i><a>') is incomplete (attributes on <a> can still carry javascript:). htmlspecialchars would show the tags as text, which is correct for plain comments but wrong if you intentionally want markup.

Common mistakes

  1. Escaping on insert and storing entities.
  2. Escaping body text but not attribute values (or the reverse).
  3. Relying on ENT_COMPAT / old defaults with single-quoted attributes.
  4. Using strip_tags as your only defence.
  5. Echoing user input into href, src, or style after only htmlspecialchars.
  6. Assuming a CAPTCHA or login removes XSS — authenticated stored XSS is still XSS.
  7. Forgetting admin pages: the attack string often targets the staff UI that prints reports.

FAQ

Does htmlspecialchars prevent all XSS?

No. It prevents HTML injection into text nodes and properly quoted attributes when used correctly. It does not secure JavaScript contexts, CSS, or unvalidated URLs. Add prepared statements, HttpOnly session cookies, and ideally a Content-Security-Policy.

htmlspecialchars vs htmlentities — which should I use?

For UTF-8 sites, prefer htmlspecialchars. It encodes the characters that matter for HTML structure and leaves £ and accented letters alone when charset is UTF-8. htmlentities encodes far more code points and historically mangled UTF-8 when the charset argument was omitted.

Do I still need ENT_QUOTES on PHP 8.1+?

Defaults already include ENT_QUOTES | ENT_SUBSTITUTE. Spelling them out documents intent and keeps copy-pasted snippets safe on older hosts. If you pass flags yourself, also pass 'UTF-8'.

Should I escape before saving to MySQL?

No for HTML escaping. Save the real text (after validation). Escape when rendering HTML. Use prepared statements for the query itself.

Is FILTER_SANITIZE_SPECIAL_CHARS enough?

It overlaps, but the filter extension’s sanitisation filters have been deprecated or changed across versions and hide the escaping contract. For HTML output, call htmlspecialchars or e() so anyone reading the template sees the defence.

What about double encoding?

If you see &amp;lt; in the page source, something escaped twice. Usually a layer escaped on save and the view escaped again. Store raw, escape once on output. If you must display entities as examples, escape the already-escaped string deliberately and say so.

A full mini page: search box with safe reflection

Putting the pieces together, here is a single-file pattern you can run with php -S localhost:8080:

<?php
declare(strict_types=1);

function e(?string $s): string
{
    return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

$q = isset($_GET['q']) ? trim((string) $_GET['q']) : '';
if (strlen($q) > 100) {
    $q = substr($q, 0, 100);
}
?>
<!DOCTYPE html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <title>Search demo</title>
</head>
<body>
  <form method="get" action="">
    <label>Search
      <input type="search" name="q" value="<?= e($q) ?>">
    </label>
    <button type="submit">Go</button>
  </form>
  <?php if ($q !== ''): ?>
    <p>You searched for: <strong><?= e($q) ?></strong></p>
  <?php endif; ?>
</body>
</html>

Try ?q=<img src=x onerror=alert(1)>. With escaping you should see the tags as text. Remove e() once and you will see why the helper exists. Length-limiting is validation; e() is escaping — both belong, neither replaces the other. If you later save searches to a database, keep using prepared statements and still escape when listing history on the page.

Defence in depth (short checklist)

Escaping is necessary but not the only layer:

  1. Escape HTML output with htmlspecialchars / e().
  2. Parameterise SQL so scripts in a comment field cannot change queries.
  3. Cookie flags (HttpOnly, Secure, SameSite) so a successful XSS steals less (set these when you start the session on login pages).
  4. Content-Security-Policy headers to block inline scripts you did not intend.
  5. Validate types — an age field should be an integer range, not free HTML.
  6. Least privilege in admin UIs — the stored XSS target is often staff-only pages that print “raw” reports.

If you only do item 1 carefully, you already avoid the majority of reflected XSS in small PHP apps. The rest matters as the app grows.

How frameworks do the same job

Twig’s default print syntax (double braces around the variable) escapes automatically; Twig autoescape-off blocks and the raw filter are the foot-guns. Blade’s default print syntax escapes; Blade’s unescaped print syntax does not. Laravel and Symfony did not invent a different rule — they made the safe default harder to skip. In plain PHP you must build that default yourself with e() and code review. When you paste old Stack Overflow snippets that echo $_GET into HTML, treat them as vulnerable until proven otherwise.

Encoding table (what becomes what)

| Character | Entity with ENT_QUOTES | Why it matters | |-----------|------------------------|----------------| | & | &amp; | Must be first conceptually; otherwise other entities break | | < | &lt; | Stops new tags | | > | &gt; | Closes tags early in some parsers | | " | &quot; | Protects double-quoted attributes | | ' | &#039; or &apos; depending on flags/doctype | Protects single-quoted attributes |

You do not need to memorise the entities — call the function. You do need to remember that ampersand appears in normal text (“AT&T”, query strings). Escaping turns it into &amp; in HTML, which is correct. If you see a bare & followed by random letters in a page that should show a company name, someone forgot to escape.

Review habits for pull requests

When you review PHP that prints variables, ask:

  • Is this value user-influenced (GET, POST, database column that users can edit, headers)?
  • Is the surrounding context HTML text, attribute, JS, URL, or CSS?
  • Is there an e() / htmlspecialchars call at the echo site?
  • Are we escaping the same value twice?

A five-second glance catches most holes. Automated tools (Psalm, PHPStan with security plugins, or grep for echo $_) help but do not replace knowing the rule. Pair this review with SQL review for string-concatenated queries — the sister bug class to XSS.

Further reading

Escape on output, validate on input, parameterise your SQL. That trio removes the majority of the boring, dangerous bugs in small PHP apps.

// note

How to read this note.

This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.

If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.

Related notes