Skip to content

CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') - PHP

Overview

XSS occurs when untrusted data is included in web output without proper encoding. PHP provides an encoding function for each output context, and Laravel and Symfony escape template output by default.

Primary Defence: Use htmlspecialchars() with ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5 and UTF-8 encoding for user-controlled output in HTML text and quoted attribute contexts, use framework auto-escaping such as Laravel Blade {{ }} and Twig {{ }}, and use context-appropriate encoding for JavaScript, URL, and CSS contexts. Use CSP and X-Content-Type-Options: nosniff as supporting controls, not replacements for output encoding.

Common Vulnerable Patterns

Direct Echo of User Input

<?php
// VULNERABLE - No encoding
$name = $_GET['name'];
echo "<h1>Welcome, $name</h1>";

// VULNERABLE - Interpolation without encoding
$comment = $_POST['comment'];
?>
<div class="comment"><?php echo $comment; ?></div>

Why this is vulnerable: Echoing user input without htmlspecialchars() lets an attacker put HTML tags and JavaScript into the page, such as <script>alert(document.cookie)</script>, which runs in the victim's browser with access to their session.

Building HTML Without Escaping

<?php
// VULNERABLE - String concatenation
function displayUser($userId) {
    $user = getUser($userId);
    $html = '<div class="profile">';
    $html .= '<h2>' . $user['name'] . '</h2>';
    $html .= '<p>' . $user['bio'] . '</p>';
    $html .= '</div>';
    return $html;  // No escaping!
}

Why this is vulnerable: The name and bio are concatenated into the markup raw, so a stored value such as <img src=x onerror=alert(1)> becomes an element rather than text, and its handler runs when the profile is rendered.

JavaScript Context Without Escaping

<script>
    // VULNERABLE - Can break out with quotes
    var message = '<?php echo $_GET['msg']; ?>';
    alert(message);
</script>

Why this is vulnerable: Injecting unescaped data into JavaScript strings allows attackers to break out using quotes and inject code like '; alert(document.cookie); //, executing arbitrary JavaScript in the user's session.

Using print_r or var_dump on User Data

<?php
// VULNERABLE - Exposes raw data
print_r($_POST);
var_dump($_GET);

Why this is vulnerable: print_r() and var_dump() write their output unencoded, so a <script> tag in any request parameter renders as markup in the page instead of appearing as debug text.

Secure Patterns

htmlspecialchars() with Correct Flags

<?php
// SECURE - Proper HTML encoding
$name = $_GET['name'] ?? 'Guest';
$safeName = htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
echo "<h1>Welcome, $safeName</h1>";

// SECURE - Function wrapper
function h($string) {
    return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}

$userBio = $_POST['bio'] ?? '';
?>
<div class="bio"><?= h($userBio) ?></div>

Why this works:

htmlspecialchars() converts the HTML-significant characters (<, >, &, ", ') into their entity equivalents (&lt;, &gt;, &amp;, &quot;, &apos;) as the script runs, so an injected script tag reaches the browser as text it displays rather than markup it executes. The ENT_QUOTES flag encodes both single and double quotes, which is what stops an attacker breaking out of an attribute like <input value="<?= $userInput ?>">. ENT_SUBSTITUTE replaces invalid byte sequences instead of returning an empty string, and ENT_HTML5 selects the HTML5 entity set. Specifying UTF-8 prevents character set manipulation attacks, where multi-byte sequences are used to slip past filters. The h() wrapper keeps the flags in one place, so call sites do not have to repeat the full signature. This is secure by default for HTML content, though JavaScript, CSS, and URL contexts each need their own encoding.

Important Flags:

  • ENT_QUOTES: Encode both single and double quotes
  • ENT_SUBSTITUTE: Replace invalid byte sequences instead of failing closed into an empty string
  • ENT_HTML5: Use HTML5 encoding rules
  • UTF-8: Specify character encoding

htmlentities() for More Comprehensive Encoding

<?php
// SECURE - Encodes all applicable characters
function escape($string) {
    return htmlentities($string, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}

$userComment = $_POST['comment'];
?>
<p><?= escape($userComment) ?></p>

Why this works:

htmlentities() encodes every character that has an HTML entity equivalent, not just the dangerous ones (<, >, &, ", '). That takes in accented letters (é → &eacute;), currency symbols (€ → &euro;), and mathematical symbols (± → &plusmn;), which is useful for international content or user input containing characters that carry semantic meaning in HTML. The flags mean the same thing they do for htmlspecialchars(): ENT_QUOTES encodes both quote types, ENT_SUBSTITUTE replaces invalid byte sequences instead of returning an empty string, ENT_HTML5 uses HTML5 encoding rules, and UTF-8 prevents character set manipulation attacks. The trade-off is readability - the raw HTML shows &eacute; where the source had é, though browsers render both identically. For XSS prevention, htmlspecialchars() with the flags above is usually sufficient and more readable; reach for htmlentities() when you want the wider coverage, which also encodes characters that can trigger unexpected behavior in some browsers and HTML parsers.

Context-Specific Encoding

HTML Context

<?php
// SECURE - HTML body content
function htmlEncode($text) {
    return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}

$message = $_GET['msg'];
echo '<div>' . htmlEncode($message) . '</div>';

Why this works:

Encoding the value before it goes into HTML body content or an attribute makes the browser read it as text rather than as code: an attacker's <script>alert('xss')</script> arrives as &lt;script&gt;alert('xss')&lt;/script&gt; and is displayed literally. The ENT_QUOTES | ENT_HTML5 flags encode both single and double quotes, which blocks quote-based injection in attributes like <div title="<?= $input ?>">, and UTF-8 prevents charset manipulation attacks that use multi-byte sequences to bypass filters. This encoding is for HTML contexts only - it will not protect a JavaScript string context (which needs JSON encoding), a CSS context (CSS escaping), or a URL context (percent-encoding). Always match the encoder to the context where the data appears.

JavaScript Context

<?php
// SECURE - JavaScript string context
function jsEncode($string) {
    // JSON encode provides proper JavaScript escaping
    return json_encode($string, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
}

$userName = $_SESSION['username'];
?>
<script>
    var currentUser = <?= jsEncode($userName) ?>;
    console.log(currentUser);
</script>

Why this works:

HTML entity encoding does not protect data that is inserted into JavaScript code, so this context needs a different encoder. json_encode() wraps the string in double quotes and, with the hex flags (JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT), converts the HTML-significant characters into unicode escapes that JavaScript reads back correctly: < becomes \u003C, > becomes \u003E, ' becomes \u0027, and " becomes \u0022. Escaping those characters is what stops a payload closing the block with </script><script>alert('xss')</script>: the browser's HTML parser never sees a tag, and the result is still valid JavaScript. Because json_encode() supplies the quotes itself, do not add your own - var x = "<?= jsEncode($data) ?>"; would wrap a second pair around an already-quoted value. The same call handles object properties and array values, so it also serves for passing whole PHP data structures to client-side code.

URL Context

<?php
// SECURE - URL parameter encoding
$query = $_GET['search'];
$searchUrl = '/search?q=' . urlencode($query);
?>
<a href="<?= htmlspecialchars($searchUrl, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8') ?>">Search</a>

Why this works:

URL contexts require percent-encoding (also called URL encoding), which converts special characters into the %XX hexadecimal form that is safe inside a URL. PHP's urlencode() encodes spaces as + and percent-encodes the characters that have special meaning in a URL (&, =, ?, /, #), so a search for admin&delete=all becomes admin%26delete%3Dall rather than a second parameter the application acts on. The example then wraps the finished URL in htmlspecialchars() because it sits inside an HTML attribute: URL encoding first to build a valid URL, HTML encoding second so the value cannot break out of the href quotes. The choice between the two functions is about how a space is represented, not about which characters survive: urlencode() follows RFC 1738 and encodes a space as +, while rawurlencode() follows RFC 3986 and encodes it as %20. Neither preserves / - both percent-encode it as %2F - so use rawurlencode() for path segments, where a + would be read literally rather than as a space, and either for query strings. Percent-encoding only makes the URL safe for inclusion in HTML; JavaScript that builds URLs on the client still needs encodeURIComponent() there.

JSON Responses

<?php
// SECURE - JSON encoding handles escaping
header('Content-Type: application/json; charset=utf-8');

$user = [
    'name' => $_GET['name'],
    'bio' => $_POST['bio']
];

echo json_encode($user, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);

Why this works:

The Content-Type: application/json header tells the browser to treat the response as data rather than renderable HTML, so nothing in it executes. The hex flags (JSON_HEX_TAG, JSON_HEX_AMP, JSON_HEX_APOS, JSON_HEX_QUOT) convert HTML-significant characters into unicode escape sequences (e.g., < becomes \u003C), which keeps the payload inert if the response is later embedded in HTML or handed to JavaScript that renders the values into the DOM. For an API, that means a script tag an attacker managed to store in the database is neutralized on the way out, rather than relying on the consumer to handle it safely.

Framework-Specific Guidance

Laravel Blade Templates

{{-- resources/views/profile.blade.php --}}
{{-- SECURE - Blade auto-escapes {{ }} --}}
<div class="user-profile">
    <h1>{{ $user->name }}</h1>
    <p>{{ $user->bio }}</p>
    <small>Joined: {{ $user->created_at }}</small>
</div>

{{-- DANGEROUS - Unescaped output --}}
<div>{!! $user->bio !!}</div>

{{-- SECURE - Sanitize before using {!! !!} --}}
@php
    $sanitized = Purifier::clean($user->richBio);
@endphp
<div class="rich-content">{!! $sanitized !!}</div>

The controller passes the values through unescaped, which is correct - escaping is the template's job and doing it here as well would double-encode:

<?php
// app/Http/Controllers/ProfileController.php
namespace App\Http\Controllers;

use Illuminate\Http\Request;

class ProfileController extends Controller
{
    public function show(Request $request, $id)
    {
        $user = User::findOrFail($id);

        // Blade automatically escapes these in {{ }}
        return view('profile', [
            'user' => $user,
            'message' => $request->query('msg', '')
        ]);
    }
}

Why this works:

Blade compiles {{ $user->name }} into <?php echo e($user->name); ?>, where e() is Laravel's encoding helper and applies htmlspecialchars() with appropriate flags. Every value rendered through {{ }} therefore has its dangerous characters (<, >, &, ", ') turned into HTML entities, without the developer asking for it. The encoding happens as the template renders - after the controller passes data to the view, before the HTTP response is sent. The {!! !!} raw echo syntax bypasses it, which is what you want for trusted markup such as admin-created rich text, but anything user-supplied has to go through a sanitizer first; the example does that with Purifier::clean(). Blade's auto-escaping covers HTML content: a JavaScript context still needs the @json directive, and an href still needs URL encoding. Because the unsafe path is the explicit one, {!! !!} is easy to spot during code review.

Laravel HTMLPurifier Package:

composer require mews/purifier
<?php
use Mews\Purifier\Facades\Purifier;

// Clean HTML before storing
$cleanHtml = Purifier::clean($request->input('content'));

$article = Article::create([
    'title' => $request->input('title'),
    'content' => $cleanHtml
]);

// config/purifier.php
return [
    'encoding' => 'UTF-8',
    'finalize' => true,
    'cachePath' => storage_path('app/purifier'),
    'settings' => [
        'default' => [
            'HTML.Doctype' => 'HTML 4.01 Transitional',
            'HTML.Allowed' => 'p,br,strong,em,ul,ol,li,a[href|title]',
            'AutoFormat.AutoParagraph' => true,
            'AutoFormat.RemoveEmpty' => true,
        ],
    ],
];

Symfony Twig Templates

{# templates/profile.html.twig #}
{# SECURE - Twig auto-escapes {{ }} #}
<div class="profile">
    <h1>{{ user.name }}</h1>
    <p>{{ user.bio }}</p>
</div>

{# DANGEROUS - Raw output #}
<div>{{ user.content|raw }}</div>

{# SECURE - Sanitize first #}
<div>{{ user.content|sanitize_html|raw }}</div>

The controller hands Twig the raw values for the same reason the Blade one does: escaping belongs to the template, and repeating it here would double-encode.

<?php
// src/Controller/ProfileController.php
namespace App\Controller;

use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class ProfileController extends AbstractController
{
    public function __construct(private readonly EntityManagerInterface $entityManager)
    {
    }

    public function show(Request $request, int $id): Response
    {
        $user = $this->entityManager->getRepository(User::class)->find($id);

        // Twig auto-escapes in templates
        return $this->render('profile.html.twig', [
            'user' => $user,
            'message' => $request->query->get('msg', '')
        ]);
    }
}

Why this works:

Twig escapes everything rendered through {{ }}, converting <, >, &, " and ' to their entity equivalents as the template compiles - the same protection Blade gives, arrived at the same way. It is on by default through Twig's autoescape setting (typically 'html'), so an injection only lands where a developer has turned escaping off. The |raw filter is that opt-out: appropriate for admin-authored rich text or content that has already been sanitized, not for raw user input. The template above pipes through sanitize_html before |raw for exactly that reason. As with Blade, the escaping covers HTML content - a JavaScript context needs the |json_encode filter, and an href needs URL encoding - and because |raw is an explicit opt-in, it is easy to identify during security review. Symfony's HTML Sanitizer component (6.1 and later) does the cleaning with a configurable allowlist, keeping safe elements and attributes and removing the rest, including <script> tags and javascript: URLs.

HTML Sanitizer Component (Symfony 6.1+):

composer require symfony/html-sanitizer
<?php
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;

$config = (new HtmlSanitizerConfig())

    ->allowSafeElements()
    ->allowElement('a', ['href', 'title']);

$sanitizer = new HtmlSanitizer($config);
$cleanHtml = $sanitizer->sanitize($userInput);

Rich HTML Sanitization

For allowing safe HTML (e.g., WYSIWYG editors):

<?php
// Use HTML Purifier library

require_once 'vendor/autoload.php';
use HTMLPurifier;
use HTMLPurifier_Config;

function sanitizeHtml($dirtyHtml) {
    $config = HTMLPurifier_Config::createDefault();

    // Set cache path
    $config->set('Cache.SerializerPath', '/tmp');

    // Define allowed elements and attributes
    $config->set('HTML.Allowed', 'p,br,strong,em,ul,ol,li,a[href|title]');

    // Encoding
    $config->set('Core.Encoding', 'UTF-8');

    // Remove empty paragraphs
    $config->set('AutoFormat.RemoveEmpty', true);

    $purifier = new HTMLPurifier($config);
    return $purifier->purify($dirtyHtml);
}

// Usage:
$userContent = $_POST['article_content'];
$cleanContent = sanitizeHtml($userContent);

// Now safe to output with minimal escaping
echo $cleanContent;

Installation:

composer require ezyang/htmlpurifier

Input Validation (Defense in Depth)

<?php
// Validation before storage

class CommentValidator {
    public static function validate($data) {
        $errors = [];

        // Validate author name
        if (!isset($data['author']) || empty(trim($data['author']))) {
            $errors[] = 'Author name is required';
        } elseif (strlen($data['author']) > 100) {
            $errors[] = 'Author name too long';
        } elseif (!preg_match('/^[a-zA-Z0-9\s]+$/', $data['author'])) {
            $errors[] = 'Author name contains invalid characters';
        }

        // Validate comment text
        if (!isset($data['text']) || empty(trim($data['text']))) {
            $errors[] = 'Comment text is required';
        } elseif (strlen($data['text']) > 1000) {
            $errors[] = 'Comment too long';
        }

        return $errors;
    }
}

// Controller:
$errors = CommentValidator::validate($_POST);
if (empty($errors)) {
    // Still encode output even after validation!
    $comment = [
        'author' => htmlspecialchars($_POST['author'], ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8'),
        'text' => htmlspecialchars($_POST['text'], ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8')
    ];
    saveComment($comment);
}

Content Security Policy

<?php
// Set CSP headers

header("Content-Security-Policy: " .
    "default-src 'self'; " .
    "script-src 'self' https://trusted-cdn.com; " .
    "style-src 'self' 'unsafe-inline'; " .
    "img-src 'self' data: https:; " .
    "frame-ancestors 'none';"
);

header("X-Content-Type-Options: nosniff");
header("X-Frame-Options: DENY");

// Or in a middleware/bootstrap file:
class SecurityHeaders {
    public static function apply() {
        if (!headers_sent()) {
            header("Content-Security-Policy: default-src 'self'");
            header("X-Content-Type-Options: nosniff");
            header("X-Frame-Options: SAMEORIGIN");
        }
    }
}

// Call early in application bootstrap
SecurityHeaders::apply();

PHP Configuration

; php.ini security settings

; Don't expose PHP version
expose_php = Off

; Disable dangerous functions
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source

; Session security
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = "Strict"

Testing

  • Test normal values containing quotes, apostrophes, ampersands, Unicode, and angle brackets.
  • Test HTML payloads such as <script>alert(1)</script>, <img src=x onerror=alert(1)>, and <svg onload=alert(1)>.
  • Test JavaScript string, URL, and attribute-breaking payloads separately because each context has different escaping rules.
  • Test stored content rendered in admin pages, emails, previews, search results, and API-driven frontend views.
  • Test framework escape opt-outs such as Blade {!! !!}, Twig |raw, and manually concatenated HTML.
  • Verify CSP blocks injected inline scripts as a secondary control, while confirming the response itself is still properly encoded.

Common Pitfalls

  • Using htmlspecialchars() for JavaScript, CSS, or URL contexts where it is the wrong encoder.
  • Forgetting ENT_QUOTES or the UTF-8 charset. Without ENT_SUBSTITUTE, invalid UTF-8 makes htmlspecialchars() return an empty string, so the field silently renders blank rather than encoded.
  • Encoding at input time and later mixing already-encoded and raw data.
  • Assuming Blade or Twig protects {!! !!} or |raw.
  • Sanitizing rich HTML and then appending unsanitized HTML afterward.
  • Treating CSP, input validation, or security headers as the primary XSS fix.

Dependencies and Installation

  • htmlspecialchars(), htmlentities(), json_encode(), urlencode(), and rawurlencode() are built into PHP.
  • HTML Purifier and Symfony HTML Sanitizer are appropriate when limited user-provided HTML must be allowed.
  • Laravel Blade and Symfony Twig provide escaped output by default; keep framework versions current.
  • Configure the application to emit Content-Type with UTF-8 and X-Content-Type-Options: nosniff.

Additional Resources