Cross-site scripting, or XSS, is one of the most common web security flaws. It happens when a website shows user input on a page without escaping it, so the browser runs the input as code. This guide explains the types of XSS, shows a vulnerable PHP example and its fix, and covers the defences that every PHP developer should use: output escaping, context-aware encoding, a Content Security Policy, and safe cookies.

Quick answer

  • XSS lets an attacker run JavaScript in your visitors’ browsers, on your own site.
  • Prevent it by escaping output: use htmlspecialchars($value, ENT_QUOTES, ‘UTF-8’) every time you print user data into HTML.
  • The right escaping depends on where the data goes: HTML, an attribute, JavaScript or a URL.
  • strip_tags() and input filtering are not enough on their own.
  • Add a Content Security Policy and HttpOnly cookies as extra layers.

 

What is XSS?

When a page includes text that came from a user, such as a search term, a comment or a name, the browser reads that text as part of the page. If the text contains HTML or JavaScript and the site did not escape it, the browser runs it, with all the rights of your site. An attacker can then steal session data that is not protected, change what the page shows, send the visitor to a fake page, or perform actions as the logged-in user.

 

Types of XSS

Type

How it works

Example

Reflected

The malicious input is in the request (usually the URL), and the page shows it straight back

A search page that prints the search term

Stored

The input is saved on the server and shown to other visitors later

A comment, a profile name or a forum post

DOM-based

JavaScript in the browser takes data from the URL or page and puts it in the page unsafely

Code that writes location.hash into the page with innerHTML

Stored XSS is often the most dangerous, because one saved comment can affect every visitor who opens the page.

 

A vulnerable PHP example

This search page prints the search term without escaping it:

<?php
echo '<h1>Results for ' . $_GET['q'] . '</h1>';

If a visitor searches for the text below, the generated HTML contains a live script tag:

q = <script>alert(1)</script>
<h1>Results for <script>alert(1)</script></h1>

The browser runs the script. A real attacker would not use alert(1), but code that targets your visitors. Because the term comes from the URL, an attacker can send someone a link that contains it.

 

The fix: escape output with htmlspecialchars()

<?php
echo '<h1>Results for ' . htmlspecialchars($_GET['q'], ENT_QUOTES, 'UTF-8') . '</h1>';

Now the same input produces harmless text:

<h1>Results for &lt;script&gt;alert(1)&lt;/script&gt;</h1>

The browser shows the characters as text instead of running them. htmlspecialchars() replaces <, >, &, double quotes and single quotes with safe codes.

echo htmlspecialchars('<script>alert(1)</script>', ENT_QUOTES, 'UTF-8');
echo "\n";
echo htmlspecialchars("O'Brien & <b>Sons</b>", ENT_QUOTES, 'UTF-8');

Output:

&lt;script&gt;alert(1)&lt;/script&gt;
O&#039;Brien &amp; &lt;b&gt;Sons&lt;/b&gt;

Always pass the flags and the character set. ENT_QUOTES makes sure that single and double quotes are both escaped, which matters inside attributes. Since PHP 8.1 the default flags also escape both kinds of quotes, but writing them out keeps your code safe on older versions and makes your intent clear.

A short helper keeps your templates tidy:

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

echo e('<img src=x onerror=alert(1)>');
&lt;img src=x onerror=alert(1)&gt;

Escape for the right context

The correct escaping depends on where the data appears.

Where the data goes

What to use

HTML text

htmlspecialchars()

An HTML attribute (always in quotes)

htmlspecialchars() with ENT_QUOTES

A JavaScript value

json_encode() with the JSON_HEX flags

A URL parameter

urlencode() or rawurlencode()

A whole URL, such as a link

Allow only http and https, then escape for HTML

CSS

Avoid putting user input in CSS

In an attribute

$attr = '" onmouseover="alert(1)';
echo '<input value="' . htmlspecialchars($attr, ENT_QUOTES, 'UTF-8') . '">';
<input value="&quot; onmouseover=&quot;alert(1)">

Without the quote escaping, the input would close the value attribute and add an event handler. Always put attribute values in quotes.

In JavaScript

Do not paste PHP values into scripts with string concatenation. Use json_encode() with flags that escape characters that can end a script block:

$flags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
echo json_encode('</script><script>alert(1)</script>', $flags);
"\u003C\/script\u003E\u003Cscript\u003Ealert(1)\u003C\/script\u003E"

In a template you could then write var name = <?= json_encode($name, $flags) ?>; and the value is safe.

In a URL

echo urlencode('a b&c=d<e>');
a+b%26c%3Dd%3Ce%3E

If you let users supply a whole link, check the scheme. A link such as javascript:alert(1) runs code when it is clicked.

$url = 'javascript:alert(1)';
$scheme = parse_url($url, PHP_URL_SCHEME);
var_dump(in_array($scheme, ['http', 'https'], true));
bool(false)

A scheme allowlist is safer than only calling filter_var() with FILTER_VALIDATE_URL.

 

Why strip_tags() is not a fix

strip_tags() removes HTML tags, but it does not escape text, and it can be used wrongly in attributes and other contexts.

echo strip_tags('<script>alert(1)</script>Hello <b>World</b>');
alert(1)Hello World

The tags are gone, but the script text is left, and the result is not safe to put into every context. Use output escaping instead.

 

If you must allow some HTML

Sometimes users should be able to write bold text or links, for example in a blog comment or a rich text editor. Do not try to clean the HTML with your own regular expressions. Use a well-tested sanitiser:

  • HTML Purifier for general PHP projects
  • wp_kses() and wp_kses_post() in WordPress

They keep only the tags and attributes that you allow.

 

Frameworks and CMS: use the built-in escaping

Platform

How to escape

CodeIgniter 4

esc($value) in views

Laravel Blade

{{ $value }} escapes. {!! $value !!} does not, so avoid it with user data

Twig

Auto-escaping is on by default. Avoid the raw filter with user data

WordPress

esc_html(), esc_attr(), esc_url(), esc_js(), and wp_kses_post()

In plain PHP, <?= $value ?> prints the value without escaping. Wrap it in your helper: <?= e($value) ?>.

 

DOM-based XSS in JavaScript

The same rule applies in the browser. Putting text into a page with innerHTML makes the browser parse it as HTML.

// Risky: the input is parsed as HTML
element.innerHTML = userInput;

// Safer: the input is shown as plain text
element.textContent = userInput;

Also be careful with document.write(), eval() and setting href or src from the URL without checking.

 

Extra layers of defence

Escaping is your main defence. These help if you miss something.

1. Content Security Policy (CSP)

A CSP header tells the browser which scripts it may run, so injected inline scripts are blocked.

header("Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'");

This policy allows scripts only from your own domain, and it blocks inline scripts. If your site relies on inline scripts or third-party scripts such as ads and analytics, you need to adjust the policy, for example with nonces. Test it carefully, and start with the report-only version (Content-Security-Policy-Report-Only).

2. HttpOnly and Secure cookies

Set these in php.ini so that JavaScript cannot read your session cookie:

session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax

This does not stop XSS, but it makes it harder for an XSS attack to steal a session.

3. Other headers

header('X-Content-Type-Options: nosniff');
header('Content-Type: text/html; charset=UTF-8');

Setting the character set explicitly stops the browser from guessing it, which attackers have used in the past.

 

Common mistakes

  • Escaping on input instead of output. Escape when you print the data, for the place it goes. Store the original text.
  • Escaping twice. You then see characters such as &lt; on the page. Escape once, at the last moment.
  • Unquoted attributes. Always put attribute values in quotes.
  • Printing $_SERVER[‘PHP_SELF’] in a form action without escaping, which is a classic XSS bug. Use an empty action or escape the value.
  • Trusting data from your own database. Stored XSS comes from the database, so escape it too.
  • Using the raw output options of a template engine for user data.
  • Allowing javascript: links in user-supplied URLs.

 

Quick checklist

  • Every value from a user, a database, a URL or an API is escaped when printed
  • You use the right escaping for HTML, attributes, JavaScript and URLs
  • Templates auto-escape, and raw output is only used for data you control
  • User-supplied HTML goes through a sanitiser such as HTML Purifier or wp_kses
  • A Content Security Policy is in place, and cookies are HttpOnly, Secure and SameSite
  • JavaScript uses textContent instead of innerHTML for plain text

 

FAQs

What is XSS in simple words?

It is a flaw where a website lets an attacker add their own script to a page, so that the script runs in other visitors’ browsers as if it came from the website.

What is the difference between XSS and SQL injection?

SQL injection targets the database on the server. XSS targets the visitors’ browsers. Both come from mixing user data with code, and both are fixed by keeping data and code separate, with prepared statements for SQL and escaping for HTML.

Is htmlspecialchars() enough to stop XSS?

For data printed as HTML text or in a quoted attribute, yes. For JavaScript, URLs and other contexts, you need the matching method, such as json_encode() or a URL scheme check.

Does a WAF stop XSS?

It can block many common attempts, but it can be bypassed. Use it as an extra layer, not as the main fix.

Can XSS happen without JavaScript enabled?

Most XSS attacks need JavaScript, but injected HTML can still change a page, add fake forms or redirect users, so escaping matters either way.