Cross-Site Scripting (XSS)

Cross-site scripting (XSS) is a defect in which text supplied by one user is written into a web page in a way the browser reads as HTML or JavaScript rather than as text. The browser cannot tell a script the developers wrote from one an attacker typed into a comment field, so the injected code runs with the victim’s session: it can read what the victim sees, act in the victim’s name or redraw the page. The OWASP Top 10:2025 counts XSS as part of its injection category, the same root cause as the SQL injection of the previous lesson, with the browser as the interpreter.

For example, a customer sets the display name qa7<b>x</b>qa7. The profile page shows it exactly as typed, because the template encoded it. In the support agents’ queue the x is bold: that page concatenated the name into HTML, so a script that any customer puts in a display name could run in the browser of every agent who opens the queue.

Why it matters

For the product, XSS turns a harmless text field into an entry point: a session cookie without HttpOnly can be read, forms can be submitted in the victim’s name, a fake login box can be drawn over the real page, and stored XSS on an admin screen hands the attacker the most privileged users. For the tester it is one of the few security defects that ordinary functional testing can find, because every feature that displays user input is a candidate.

It is also a defect you can find without attack tools and without harm: a unique marker typed into a field and a look at the page source answer the question "is this value treated as text or as markup?". Knowing the three variants tells you where to type the marker and where to look for it.

Reflected, stored and DOM-based XSS

Every XSS has the same shape: an untrusted value enters at a source and reaches a sink that interprets it as code. The variants differ in where the value waits and who turns it into markup, and the OWASP Web Security Testing Guide tests them separately because the steps and the people exposed differ.

Reflected XSS: the value travels in the request, usually a query parameter or form field, and the response echoes it at once, as in "0 results for …" on a search page. Only the sender sees it, so an attacker must get the victim to send the request, usually by opening a crafted link. Stored XSS: the value is saved (a review, a ticket, a profile field, a file name) and rendered later to other users, staff included. No link is needed and every viewer is exposed, which is why it is usually the most severe. DOM-based XSS: the server response is clean, but the page’s own JavaScript reads a source such as location.search, location.hash, document.referrer or a postMessage event and writes it into a sink such as innerHTML, document.write or eval. The part of a URL after # is never sent to the server, so this variant can leave no trace in server logs, and no server-side encoding can fix it.

Stored XSS: a probe saved by customer A comes back inside agent B’s page; with HTML encoding at output the same stored text is shown literally.
  • Without output encoding

    • Customer A your own test account
    • Application server and database
    • Agent B browser with a staff session
    1. 1 From Customer A To Application: Submit a review with the marker probe title=qa7<b>x</b>qa7
    2. 2 Application internally: Store the title exactly as received Storage is not the defect: a database holds text
    3. 3 From Agent B To Application: Open the review queue GET /agent/reviews
    4. 4 From Application To Agent B: HTML built by concatenating the stored title reply <td>qa7<b>x</b>qa7</td> Fail
    5. 5 Agent B internally: The browser parses the probe as markup: x is bold A real script here could run with the agent’s session Fail

    Outcome: Stored XSS: a script any customer writes can run for every agent who opens the queue. Fail

  • With output encoding

    • Customer A your own test account
    • Application server and database
    • Agent B browser with a staff session
    1. 1 From Customer A To Application: Submit a review with the marker probe title=qa7<b>x</b>qa7
    2. 2 Application internally: Store the title exactly as received Storage is not the defect: a database holds text
    3. 3 From Agent B To Application: Open the review queue GET /agent/reviews
    4. 4 From Application To Agent B: The template encodes the title as it writes the page reply <td>qa7&lt;b&gt;x&lt;/b&gt;qa7</td> Pass
    5. 5 Agent B internally: The browser shows the characters literally The page text reads qa7<b>x</b>qa7; nothing becomes markup Pass

    Outcome: The same stored value is harmless because it leaves the server as text. Pass

Reflected  - travels in the request, echoed at once
  GET /search?q=qa7<b>x</b>qa7
  <h2>0 results for qa7<b>x</b>qa7</h2>

Stored     - saved now, rendered to someone else later
  POST /reviews        title=qa7<b>x</b>qa7
  GET  /agent/reviews  (agent B, next day)
  <td>qa7<b>x</b>qa7</td>

DOM-based  - server response clean, the page script writes it
  https://shop.example/help#qa7<b>x</b>qa7   (the part after # never reaches the server)
  status.innerHTML = 'Showing: ' + decodeURIComponent(location.hash.slice(1));

Safe probes: is the value text or markup?

You do not need to run a script to prove XSS. The defect is "user input is written into the page without encoding", and a visible, harmless probe proves it. Build the probe from a unique marker such as qa7 plus the characters that matter in HTML: <, >, ", ' and &. The marker lets you search the rendered page and its HTML source (View Page Source, Ctrl+U) for every place the value landed, including places you did not expect: the <title>, an attribute, an email, a CSV export, an error message.

Read the result in the source, not only on screen. qa7&lt;b&gt;x&lt;/b&gt;qa7 means the value was HTML-encoded and is safe in that position; qa7<b>x</b>qa7 between tags means markup was accepted; in an attribute, check whether your quote closed it. The Elements panel shows the DOM, not the source: there the safe value is one text node, while accepted markup appears as a separate b element. Use an executing probe, one that only displays the marker, when the team needs proof that script runs, and only in a test environment with your own accounts and written permission. Remove stored probes afterwards: they stay in the database and surprise colleagues weeks later.

  • Enter the probe wherever a user can type or upload: forms, URL parameters, file names, imported files, API fields a page shows later, and request headers the application reflects, such as User-Agent or Referer.
  • Look at every output, not only the one next to the input: lists, detail pages, admin queues, notifications, previews, printed or PDF versions, and JSON a page embeds in a <script> block.
  • For DOM-based XSS put the probe in the fragment (#…) and in parameters the page’s JavaScript reads, and watch the DOM in DevTools rather than the server response.
  • Never probe a system you do not own or have written permission to test: unauthorised probing may be unlawful even without a script, and a stored probe in production is seen by real users.
Probe                                 What a hit means
qa7<b>x</b>qa7                        "x" in bold: HTML text context is not encoded
qa7"'>x                               your quote closes the attribute in the source
qa7<script>alert('qa7')</script>      a script parsed from the response executes
qa7<img src=x onerror=alert('qa7')>   runs via innerHTML too, where <script> does not

Executing probes: test environment, own accounts, written permission, then remove.

Try it: Special Character Dataset Generator Try it: URL Encoder

Defences: output encoding by context, sanitisation and CSP

The primary defence is output encoding matched to the context, which the lesson on input validation and output encoding introduced. For XSS testing, what matters is where it usually fails. First, the wrong context: HTML entities do not protect a value inside a <script> block, an event-handler attribute such as onclick or a style, and the OWASP Cross Site Scripting Prevention Cheat Sheet calls such places dangerous contexts, where encoding alone may not prevent XSS, and advises passing user values as data instead of writing them into code; a user-supplied link in href also needs its scheme checked against an allow-list such as https:, so that javascript: is refused, because encoding leaves the scheme untouched. Second, bypassed defaults: template engines and frameworks encode HTML text automatically, so the defects hide in their escape hatches, such as dangerouslySetInnerHTML, v-html or a direct innerHTML assignment. Third, the browser side: in DOM-based XSS no server encoding takes part, and the fix is a safe sink such as textContent instead of innerHTML.

Input validation helps where the format is strict (a date, a quantity) but cannot replace encoding: the name O’Brien, a message containing <3 and a product called A&B are legitimate. When HTML is itself a feature, as in a rich-text comment, encoding would destroy it, and the defence is an allow-list sanitiser that keeps <b> and <a href> but strips scripts and event handlers.

A Content Security Policy (CSP) is the second layer. The Content-Security-Policy response header tells the browser which scripts may run: script-src 'self' allows only scripts from the site’s own origin, a 'nonce-…' or hash allows specific inline scripts, and 'unsafe-inline' lets every inline script and event handler run, which switches that protection off (current browsers ignore it when a nonce or hash is also listed). With a strict policy an injected <script> or onerror handler is blocked and logged in the console, which limits the damage of an encoding defect without repairing it. Read the policy with the Security Header Analyzer or the HTTP Headers Parser and check script-src, or default-src when it is absent. Content-Security-Policy-Report-Only reports violations and blocks nothing; teams use it to try a policy before enforcing it.

Where the value lands       Defect seen in the source         Defence
HTML text    <td>...</td>   <td>qa7<b>x</b>qa7</td>           HTML entity encoding
Attribute    value="..."    value="qa7"'>x"                   entities, quoted attribute
Link         href="..."     href="javascript:..."             allow-list the scheme
DOM sink     innerHTML      <b> rendered from location.hash   el.textContent = value
Rich text    comment        an onerror= attribute survives    allow-list sanitiser

Strict:  Content-Security-Policy: script-src 'self' 'nonce-R4nd0m'; object-src 'none'
Weak:    Content-Security-Policy: script-src 'self' 'unsafe-inline' *

Try it: HTML Entity Encoder Try it: Security Header Analyzer Try it: HTTP Headers Parser

Worked example: a review that reaches the support desk

A shop lets customers review products and shows new reviews to support agents in a queue. You test in staging with two accounts, customer A and agent B, under the team’s written permission for security checks. As A you submit a review titled qa7<b>x</b>qa7 with the body qa7"'>x. The product page shows the title literally and its source has qa7&lt;b&gt;x&lt;/b&gt;qa7: encoded. As B, the queue shows x in bold and its source has <td>qa7<b>x</b>qa7</td>: stored XSS reaching staff. The body is shown as typed on both pages. The search page answers /search?q=qa7%3Cb%3Ex%3C%2Fb%3Eqa7 with "0 results for qa7x", x in bold: reflected XSS. Every response carries Content-Security-Policy: script-src 'self' 'unsafe-inline', so the policy would not stop a real script in either place.

  • Finding 1, Critical: review titles are rendered as HTML in the agent queue. Evidence: the input, the source line, both roles, a screenshot.
  • Finding 2, Major: the search term is echoed as HTML on the results page. Evidence: the exact URL and the source line.
  • Finding 3, Minor (hardening): the CSP allows 'unsafe-inline' scripts and would not limit findings 1 and 2.
  • Confirmation testing after the fix: repeat the probes and expect the encoded form in the source, and check that a legitimate title such as Fast & cheap still displays as typed rather than as Fast &amp; cheap.

Common mistakes

  • Testing only the field where the value is entered, not every page, role and export that displays it later: stored XSS lives in the output.
  • Using one generic payload such as <script>alert(1)</script> everywhere: a filter that blocks the word "script" stops it while a <b> probe still renders, and the missing encoding remains.
  • Running executing or stored probes in production, on shared environments or on systems you have no written permission to test, and leaving stored probes behind.
  • Taking client-side validation, a request blocked by a web application firewall or a CSP violation in the console as proof that the defect is fixed.
  • Checking only the server response for DOM-based XSS: the fragment never reaches the server, so inspect the DOM in the browser.
  • Recording the CSP as "present" without reading script-src: a policy with 'unsafe-inline' or a wildcard host does little against XSS.

The QA perspective

On a team, XSS checks belong in the functional testing of every feature that shows user-supplied text, not in a separate security phase: add the marker probe to the negative tests of each form, and when a story adds rich text, a preview, an export or a new admin screen, ask in refinement how the value will be encoded and whether the template’s default escaping is bypassed. Ask developers where innerHTML, v-html or dangerouslySetInnerHTML are used and which sanitiser applies; ask the security team whether the CSP is enforced or report-only.

Report a finding like any defect, with security framing: the exact input, the page and role where it rendered as markup, the line from the HTML source, the environment, the response headers and your severity reasoning (who sees it, with which session). Do not attach a working exploit; the security team confirms exploitability. Verify the fix with the same probes plus a legitimate value containing & and quotes, and ask whether the same template is used elsewhere, because an encoding defect is rarely alone.

Practice exercise

Plan the XSS tests for a feedback widget

Your task

A customer feedback feature is deployed on a staging site you are authorised to test; its pages and response header are described below. For each page decide which XSS variant it could have, write the exact probe and where you would look for the marker, describe the secure result, and assess the CSP.

Material
Staging site you are authorised to test: https://feedback.staging.example

Form    POST /feedback      fields: name, message
Pages   /wall               every name and message, shown to all visitors
        /admin/queue        new messages, shown to moderators (role: admin)
        /thanks?name=...    "Thank you, <name>" after submitting
        /wall#<id>          page script:
                              document.getElementById('status').innerHTML =
                                'Showing: ' + decodeURIComponent(location.hash.slice(1));

Response header on every page:
  Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'
Steps
  1. Classify each page as a reflected, stored or DOM-based candidate and say who would be exposed.
  2. Write the exact input or URL you would use with the marker qa7, and name the output you would inspect.
  3. Describe the secure result for each page as it should appear in the HTML source or the DOM.
  4. Read the Content-Security-Policy value and decide how it affects severity and what goes in the report.
Show the model answer
Model answer

/wall and /admin/queue: stored candidates; /wall exposes every visitor and /admin/queue exposes moderators, which is more severe. From your own account submit two entries, one with qa7<b>x</b>qa7 in both fields and one with qa7"'>x in both, so every output is probed whichever field it shows, then open both pages as a visitor and as a moderator. Secure result: the source contains qa7&lt;b&gt;x&lt;/b&gt;qa7, a quote that lands inside an attribute appears as &quot;, and the page shows the characters literally. Delete the stored probes when you finish.

/thanks?name=: reflected candidate, exposing anyone who opens a crafted link. Open /thanks?name=qa7%3Cb%3Ex%3C%2Fb%3Eqa7 (the probe URL-encoded) and inspect the heading and the <title>. Secure result: the encoded form in the source, and Thank you, qa7<b>x</b>qa7 shown as text.

/wall#…: DOM-based candidate, exposing anyone who opens a crafted link. Open /wall#qa7<b>x</b>qa7 and inspect the status element in the Elements panel; the server never receives the fragment, so its response and logs prove nothing. Secure result: the script uses textContent (or encodes before innerHTML) and the status reads Showing: qa7<b>x</b>qa7 literally.

CSP: script-src is present, so it, not default-src 'self', governs scripts, and script-src 'self' 'unsafe-inline' lets any inline script run, so it would not limit an injected script on any of these pages. Report it as a separate hardening finding. If the probes confirm the defects, classify the stored cases as Critical, /admin/queue first because moderators hold more privileges, and the reflected and DOM-based cases as Major, each with input, location, source line and role.

Frequently asked questions

Is <script>alert(1)</script> a safe test payload?

The alert itself is harmless, but a stored one interrupts every user who opens the page, and testing without permission can be unlawful whatever the payload. Start with a markup probe such as qa7<b>x</b>qa7, which proves the same missing encoding; use an executing probe only in a test environment, with your own accounts and permission, and remove it afterwards.

If the input is validated, is XSS prevented?

Not reliably. Validation rejects clearly wrong values and is right for strict formats, but names, messages and product titles legitimately contain <, & and quotes, and values also arrive through APIs and imports. The defence that works everywhere is encoding for the output context at the point the value is written into the page.

The CSP blocked my probe. Is the page secure?

No. The encoding defect still exists and CSP is a mitigation: a policy can be weakened later, browsers and contexts differ, and a report-only policy blocks nothing. Report the encoding defect as the primary finding and note the CSP as what limited its impact.

References

Practise with Mutqan tools

Escape HTML special characters (<, >, &, quotes) into entities to prevent XSS.

Encoding

Percent-encode text for safe use in URLs, query strings and form data.

Encoding

Grade HTTP security headers (CSP, HSTS, X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP/CORP) from pasted headers or a public URL.

Validation

Parse raw HTTP request/response headers into a structured table with explanations.

Web

Go deeper

Your progress on this topic

Part of the QA Roadmap