In PHP templates, why must an attribute value escaped with htmlspecialchars() still be quoted, and which attributes can escaping not make safe?
answer
- space ends an unquoted value
- spaces, = and backticks survive
- ENT_QUOTES covers single-quoted attributes
- event handlers are JavaScript context
- href needs a scheme check
basics
~20 shtmlspecialchars() 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
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
Recall that every attribute value must be in quotes and go through htmlspecialchars(), and that double quotes are the safe default.
Explain which characters end quoted and unquoted values, why ENT_QUOTES matters for single-quoted attributes, and the 8.1 default change.
Identify attributes where escaping is not enough, event handlers, style and URL attributes, and show the data-attribute and scheme allow-list patterns.
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 `"`. ## 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. `'` 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