XSS Prevention
What Is Cross-Site Scripting? In simple terms: Cross-site scripting (XSS) happens when an attacker manages to get their own JavaScript into your web page, so it runs in your visitors' browsers. From t
What Is Cross-Site Scripting?
In simple terms: Cross-site scripting (XSS) happens when an attacker manages to get their own JavaScript into your web page, so it runs in your visitors' browsers. From there, the script can do anything the user can: read the page, send requests in the user's name, show a fake login form, or redirect to a malicious site. It usually happens when user-supplied text is printed into HTML without escaping.
Technically: XSS is an injection vulnerability in the output of a web application. The browser cannot distinguish the application's own HTML and JavaScript from attacker-controlled content inserted into it. The primary defense is context-aware output encoding: every dynamic value is encoded for the exact place where it appears (HTML text, attribute, URL, JavaScript). A Content Security Policy (CSP) adds a second layer of protection.
Types of XSS
| Type | How the Payload Arrives | Example |
|---|---|---|
| Reflected | In the current request, reflected into the response | search.php?q=<script>…</script> displayed as "Results for …" |
| Stored | Saved in the database and shown to other users | A product review or profile bio containing a script |
| DOM-based | Client-side JavaScript inserts untrusted data into the page | element.innerHTML = location.hash |
Stored XSS is especially dangerous: every visitor of the affected page is attacked, including administrators.
Escaping for HTML: htmlspecialchars()
<?php
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$comment = '<script>alert("hacked")</script>';
$name = '" autofocus onfocus="alert(1)';
echo "<p>" . e($comment) . "</p>\n";
echo '<input name="name" value="' . e($name) . '">' . "\n";
Output
<p><script>alert("hacked")</script></p>
<input name="name" value="" autofocus onfocus="alert(1)">
How It Works
| Character | Becomes | Why |
|---|---|---|
< and > | < and > | Cannot start or end tags |
& | & | Cannot start entities |
" | " | Cannot end double-quoted attributes |
' | ' | Cannot end single-quoted attributes (ENT_QUOTES) |
The browser displays the comment as text, and the name stays inside the value attribute instead of adding an event handler. ENT_SUBSTITUTE replaces invalid UTF-8 instead of returning an empty string. Always quote HTML attributes — escaping does not protect unquoted attributes (value=<?= e($x) ?>). See Escaping Output (htmlspecialchars) for details.
Different Contexts Need Different Encoding
| Context | Example | Correct Approach |
|---|---|---|
| HTML text | <p>…</p> | htmlspecialchars() |
| Quoted HTML attribute | <input value="…"> | htmlspecialchars() with ENT_QUOTES |
| URL parameter | <a href="/search?q=…"> | rawurlencode() or http_build_query(), then htmlspecialchars() for the attribute |
Full URL in href/src | <a href="…"> | Validate the scheme (http/https only), then htmlspecialchars() |
| JavaScript data | <script>const data = …;</script> | json_encode() with JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT |
CSS, event handlers (onclick), <script> code | — | Never insert untrusted data; use data attributes and external scripts instead |
URLs: Block javascript: Links
htmlspecialchars() does not stop <a href="javascript:alert(1)"> — the text contains no special characters. User-provided URLs (website fields in profiles, links in comments) must be validated:
<?php
function safeUrl(string $url): string
{
$url = trim($url);
if (preg_match('#^/(?![/\\\\])#', $url)) {
return $url; // a local path like /courses
}
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
return in_array($scheme, ['http', 'https'], true) ? $url : '#';
}
foreach (['https://gocourse.in/php', '/courses', 'javascript:alert(1)', 'JaVaScRiPt:alert(1)', '//evil.example'] as $url) {
echo str_pad($url, 24) . '→ ' . safeUrl($url) . "\n";
}
Output
https://gocourse.in/php → https://gocourse.in/php
/courses → /courses
javascript:alert(1) → #
JaVaScRiPt:alert(1) → #
//evil.example → #
Only http, https, and local paths are allowed; everything else — javascript:, data:, vbscript:, protocol-relative URLs — is replaced. The result is still escaped with e() when printed into the attribute.
JavaScript: Pass Data as JSON
<?php
$productName = '</script><script>alert(1)</script>';
$json = json_encode($productName, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
echo "<script>const productName = $json;</script>\n";
Output
<script>const productName = "\u003C\/script\u003E\u003Cscript\u003Ealert(1)\u003C\/script\u003E";</script>
JSON_HEX_TAG encodes < and > as \u003C and \u003E, so the value cannot close the <script> element. JavaScript still sees the original string. An even cleaner pattern stores data in an attribute — <div id="app" data-product="<?= e(json_encode($product)) ?>"> — and reads it with JSON.parse(element.dataset.product).
User-Generated Rich Text
Comments, product descriptions, or blog posts sometimes need formatting (bold, links, lists). Escaping everything would remove the formatting; allowing raw HTML would allow scripts.
| Approach | When |
|---|---|
Plain text + nl2br(e($text)) | Most comments and messages |
| Markdown converted with a safe configuration (raw HTML disabled) | Formatted content written by users |
HTML sanitizer with an allow-list of tags and attributes (for example, HTML Purifier or symfony/html-sanitizer) | Rich-text editors that produce HTML |
Never try to remove "dangerous" tags with regular expressions or strip_tags() with allowed tags — attributes such as onerror and many encoding tricks get through.
DOM-Based XSS in JavaScript
The same rules apply in the browser. Data from fetch() responses, location, or localStorage must not be inserted as HTML:
// Vulnerable
results.innerHTML = `<li>${product.name}</li>`;
// Safe
const li = document.createElement('li');
li.textContent = product.name;
results.append(li);
Use textContent, setAttribute() (with validated URLs), and DOM methods; avoid innerHTML, outerHTML, insertAdjacentHTML(), document.write(), and eval() with untrusted data (see AJAX Live Search).
Defense in Depth: Content Security Policy
A Content Security Policy tells the browser which scripts may run. With a nonce-based policy, injected scripts without the correct nonce are blocked even if escaping was forgotten somewhere:
<?php
$nonce = base64_encode(random_bytes(16));
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-$nonce'; object-src 'none'; base-uri 'self'");
?>
<script nonce="<?= $nonce ?>">
// allowed: carries the nonce of this response
</script>
- A new random nonce is generated for every response.
- Inline scripts without the nonce, inline event handlers (
onclick="…"), and scripts from other domains are blocked. - Start with
Content-Security-Policy-Report-Onlyto find violations before enforcing the policy.
Additional layers:
| Measure | Effect |
|---|---|
HttpOnly session cookies | Scripts cannot read the session cookie (see Secure Cookies) |
| Template engines with auto-escaping (Twig, Blade) | Values are escaped by default; raw output must be requested explicitly |
| Input validation | Rejects unexpected data early (but is not a substitute for output encoding) |
Common Mistakes
Escaping on Input Instead of Output
Effect: Data is double-escaped in some places and unescaped in others.
Fix: Store raw data; escape at the moment of output, for the context.
Forgetting Stored Data
Effect: Values from the database are trusted and printed unescaped.
Fix: Escape every dynamic value, regardless of its source.
Unquoted Attributes or URLs Without Scheme Checks
Effect: Escaping is bypassed.
Fix: Always quote attributes and validate URL schemes.
Using innerHTML With Data
Effect: DOM-based XSS.
Fix: Use textContent and DOM methods.
Best Practices
- Escape every dynamic value for its output context; use a short
e()helper or an auto-escaping template engine. - Validate user-provided URLs and allow only safe schemes.
- Pass data to JavaScript with
json_encode()and theJSON_HEX_*flags, or via data attributes. - Sanitize rich text with a proven allow-list sanitizer.
- Add a nonce-based Content Security Policy and
HttpOnlycookies as additional layers.
Official reference: https://www.php.net/manual/en/function.htmlspecialchars.php