skip to content

In PHP templates, why must an attribute value escaped with htmlspecialchars() still be quoted, and which attributes can escaping not make safe?

level: middleimportance: should knowfreq 48%

answer

  1. space ends an unquoted value
  2. spaces, = and backticks survive
  3. ENT_QUOTES covers single-quoted attributes
  4. event handlers are JavaScript context
  5. href needs a scheme check

basics

~20 s

htmlspecialchars() encodes quotes and angle brackets but not spaces or equals signs, so an unquoted attribute value can still be split into new attributes. Always quote the value; and for event handlers, style or href, escaping alone is not enough.

solid answer

~50 s

`htmlspecialchars()` protects an attribute value by encoding the quote character that would end it, which only works if the value **is quoted**. In `<input value=<?= e($q) ?>>` a search term like `x onfocus=alert(1) autofocus` contains no character the function encodes, so the space ends the value and the rest becomes new attributes. Quote every attribute, preferably with double quotes; with `ENT_QUOTES`, the default since PHP 8.1, single-quoted attributes are covered too. Some attributes are dangerous even when quoted and escaped: in `onclick` or other event handlers the browser decodes the entities and then runs the text as JavaScript, `style` is parsed as CSS, and an `href` or `src` escaped with `htmlspecialchars()` still accepts a `javascript:` URL. Those need a different approach: no user data in handlers, JSON in data attributes, and a scheme allow-list for URLs.

code

php · 15 lines
php
<?php
declare(strict_types=1);

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

$query = $_GET['q'] ?? '';   // e.g. "Lisbon onfocus=alert(1) autofocus"
?>
<!-- Broken: nothing in the query is encoded, the space starts a new attribute -->
<input name=q value=<?= e($query) ?>>

<!-- Safe: quoted, so the whole query stays one value -->
<input name="q" value="<?= e($query) ?>">

go deeper

for a junior

Recall that every attribute value must be in quotes and go through htmlspecialchars(), and that double quotes are the safe default.

for a middle

Explain which characters end quoted and unquoted values, why ENT_QUOTES matters for single-quoted attributes, and the 8.1 default change.

for a senior

Identify attributes where escaping is not enough, event handlers, style and URL attributes, and show the data-attribute and scheme allow-list patterns.

for a principal

Push for template conventions and linting that forbid unquoted attributes and dynamic handlers, so reviews do not have to catch each case by eye.

## How an attribute value ends The HTML parser reads an attribute value in one of three ways: - **double-quoted**: the value ends at the next `"`; - **single-quoted**: the value ends at the next `'`; - **unquoted**: the value ends at whitespace or `>`, and several other characters behave oddly. `htmlspecialchars()` works by encoding the character that would end the value early. For quoted values that character is the quote, which the function encodes (`"` always, `'` with `ENT_QUOTES`). For unquoted values the terminators are whitespace and `>`: the function encodes `>`, but it leaves spaces, tabs, `=` and backticks untouched. ## The unquoted-attribute break Consider a travel site that re-fills the search box with the last query: `<input name=q value=<?= e($query) ?>>` With the query `Lisbon onfocus=alert(document.cookie) autofocus`, the escaped output is unchanged, because it contains no character in the translation table. The browser reads `value=Lisbon`, then an `onfocus` attribute, then `autofocus`, and runs the script as soon as the page loads. The fix is only a pair of quotes: `<input name="q" value="<?= e($query) ?>">` Now the space is part of the value and a `"` in the query becomes `&quot;`. ## Single quotes and the 8.1 default Before PHP 8.1 the default flag was `ENT_COMPAT`, which encodes `"` but not `'`. A template using single-quoted attributes, `value='<?= htmlspecialchars($q) ?>'`, could be broken with an apostrophe. Since 8.1 the default includes `ENT_QUOTES`, but any code passing explicit flags must include it itself. Two defensive habits: 1. always pass `ENT_QUOTES | ENT_SUBSTITUTE` explicitly, through one helper; 2. use double quotes for every attribute, so the template does not rely on the flag either way. ## Attributes that escaping cannot fix Quoting and `htmlspecialchars()` make a value safe **as an attribute string**. Some attributes then hand that string to another interpreter, after the browser has decoded the entities: | Attribute | What the browser does with the decoded value | Safer approach | |---|---|---| | `onclick`, `onload`, other `on*` | runs it as JavaScript | never put user data here; attach handlers in script and read data from a `data-*` attribute | | `style` | parses it as CSS | use fixed class names chosen from an allow-list | | `href`, `src`, `action`, `formaction` | follows it as a URL, including `javascript:` | build the URL with `rawurlencode()` for its parts and allow-list the scheme | For example, a review author's "website" link rendered as `<a href="<?= e($url) ?>">` stays clickable script when `$url` is `javascript:alert(1)`: there is nothing for `htmlspecialchars()` to encode. The defence happens before output, by checking that the scheme is `http` or `https`. ## data-* attributes for passing values to scripts When client-side code needs a value, put it in a `data-*` attribute and read it with the DOM API: - a string: `data-city="<?= e($city) ?>"`; - structured data: `data-hotel="<?= e(json_encode($hotel, JSON_THROW_ON_ERROR)) ?>"`, JSON first, then HTML-escaped because it sits in an attribute. The script reads `element.dataset.hotel` and parses it; no user data is ever executed as code. ## A short checklist for attributes - Every attribute value is quoted, with double quotes. - Every dynamic value goes through the same `htmlspecialchars()` helper with `ENT_QUOTES | ENT_SUBSTITUTE`. - No dynamic value lands in an `on*` handler or a `style` attribute. - Every dynamic URL is assembled from encoded parts and has an allow-listed scheme. - Attribute *names* are never taken from user input. ## Testing attribute escaping A handful of payloads catches most template mistakes. Render each through the template and check that the output contains no new attribute and no unescaped quote: 1. `x" autofocus onfocus="alert(1)`: tests double-quoted attributes; 2. `x' autofocus onfocus='alert(1)`: tests single-quoted attributes and the `ENT_QUOTES` flag; 3. `x autofocus onfocus=alert(1)`: tests for unquoted attributes, which it breaks even when escaped; 4. `javascript:alert(1)`: tests URL attributes, which need a scheme check; 5. a string with an invalid UTF-8 byte: tests `ENT_SUBSTITUTE`, since without it the value vanishes. These fit naturally into a unit test of the escaping helper plus a rendering test of the templates that use it.

  • Why does htmlspecialchars() not protect a value placed inside onclick="..."?
    The browser first decodes the entities in the attribute, then hands the result to the JavaScript engine. `&#039;` becomes `'` again before the script parser sees it, so a value can close a string literal and add code. Keep user data out of handlers; pass it through a `data-*` attribute and read it from script.
  • Is a user-supplied link safe once it is in a quoted href and passed through htmlspecialchars()?
    No. `javascript:alert(1)` contains nothing the function encodes, and the browser executes it when the link is clicked. Validate the URL and allow-list the `http` and `https` schemes before output, then escape it for the attribute.

saying these in an interview costs you the question

  • Quotes around attribute values are optional once the value is escaped
  • htmlspecialchars() encodes spaces and equals signs
  • Escaped values are safe inside onclick and other event handlers
  • htmlspecialchars() blocks javascript: URLs in href attributes
  • Single-quoted attributes are safe with any htmlspecialchars() flags