This article explains stored XSS (persistent cross-site scripting), a vulnerability where an attacker injects malicious code into user-generated content that a site saves and displays to every visitor. Unlike reflected XSS, stored XSS requires no social engineering—victims are compromised simply by visiting a trusted page. The article demonstrates the attack through a practical lab exercise and explains why this vulnerability is particularly dangerous in real-world applications.
- Stored XSS persists across page reloadsStored XSS differs from reflected XSS in one critical way: the site saves the attacker's payload to its database. Every time a page loads, the code reads the stored comment and renders it as HTML, causing the malicious script to execute for every visitor, not just the person who clicked a link. This persistence makes it inherently more dangerous since victims need only visit a normal page they trust.
- Attack vector bypasses link-based social engineeringReflected XSS requires tricking users into clicking a malicious link, but stored XSS needs no user interaction or attacker communication. Victims are compromised automatically when they view any page containing the stored payload. This fundamentally changes the threat model, as attackers can compromise administrators and moderators simply by having them open moderation screens to review content.
- innerHTML rendering without escaping enables injectionThe DevBlog lab demonstrates how developers often use innerHTML to display comments without escaping the content first. When a comment body is inserted directly as HTML rather than as text, markup and event handlers execute in the browser. The lab shows how an image tag with a deliberate error and an error handler can execute code that persists even after page refresh.
- Script tags don't execute via innerHTMLThe article notes that script tags inserted through innerHTML do not run, requiring attackers to use alternative elements with event handlers instead. This is why the lab walkthrough uses an image tag with an onerror handler rather than a direct script tag, demonstrating that attackers must adapt their payload structure based on how the target site renders content.
- Developers assume stored data is already safeThe root cause of stored XSS is a false trust assumption: developers often escape input when it enters the system or forget to escape it when it leaves, assuming anything already in the database is safe. However, stored data is simply user input waiting to be rendered. Without escaping on output, the site serves attacker-controlled code with its full authority to whoever views the page.
- Admin and moderator access amplifies severityStored XSS in admin panels and moderation screens is especially valuable to attackers because payloads execute with administrative privileges. An attacker can place a payload in a comment, waiting for a moderator to open the queue, and then create accounts, change settings, or perform other privileged actions. The article identifies this as why severity assessment must consider who can see the payload: regular users, other users, or staff.
- Proven defenses require layered approachThe article prescribes multiple complementary defenses: escape all output using context-aware encoding for HTML, attributes, and JavaScript; use proven sanitiser libraries like DOMPurify with strict allowlists rather than hand-written filters; implement Content Security Policy to block inline event handlers; and mark session cookies HttpOnly to prevent script access. No single defense is sufficient—all layers matter.
- Real-world example demonstrates rapid propagationThe Samy worm of 2005 exploited stored XSS in MySpace profiles to automatically add itself as a friend to everyone viewing an infected profile and copy itself onto their profiles. This self-propagating attack reached over one million accounts in about a day, illustrating how stored XSS can scale exponentially without user interaction or additional attacker effort.
Bottom line: Stored XSS is fundamentally more dangerous than reflected XSS because it requires no user interaction—victims are compromised by simply visiting a trusted page—and it often affects administrators with elevated privileges. The only reliable defense is to treat all stored data as untrusted input, escape it on every output using proven libraries, and implement multiple layers of protection including Content Security Policy.
📋 In this walkthrough
What this lab teaches
Stored XSS, also called persistent XSS, is the same bug as reflected XSS with one big difference: the site saves your payload. Every time the page loads, it reads the stored comment and writes it into the page as HTML, so the script runs for every visitor.
That makes it more dangerous. The attacker does not need to trick anyone into clicking a link. Victims just have to visit a normal page on a site they trust.
How DevBlog's comments work
Comments are saved to the page’s storage and re-rendered on every visit. The rendering code reads each comment body and inserts it with innerHTML, with no escaping. The lab is solved when your stored comment calls the lab’s solve function, and it should still fire after you refresh the page.
The attack approach
- Post a normal comment first and confirm it is saved and shown after a refresh.
- Post a comment with a little harmless HTML, like bold text, to confirm markup is rendered rather than shown as text.
- As in the reflected XSS lab, a script tag will not run through
innerHTML. Use an element with an event handler instead. - Refresh the page. If the payload fires again, it is truly stored.
Full walkthrough (spoiler)
Stuck after a few attempts? Open the steps below. Solving it yourself first is worth more XP, and more learning.
Show the step-by-step solution
- Write a comment containing an image tag whose source is deliberately broken.
- Add an error handler to that image that calls the lab’s solve function with the flag from the objective.
- Post the comment. The image fails to load and the handler runs straight away.
- Refresh the page. The comment is loaded from storage, rendered again, and the handler fires again. That persistence is what makes it stored XSS.
Why it works
The site trusts its own database. Developers often escape input on the way in, or forget to escape it on the way out, assuming anything already saved is safe. But stored data is just user input that has been waiting. When it is written into the page as HTML, it runs with the site’s full authority for whoever is viewing, including moderators and admins who open the comment queue.
Where this happens in the real world
The best-known example is the Samy worm of 2005. A stored XSS in MySpace profiles added its author as a friend to everyone who viewed an infected profile, and copied itself onto their profile too. It spread to over a million accounts in about a day. Stored XSS in admin panels is especially valuable to attackers, because a payload that fires for an administrator can create accounts or change settings.
How to defend against it
- Escape on output, every time, using context-aware encoding for HTML, attributes and JavaScript.
- If you must allow rich text, use a proven HTML sanitiser such as DOMPurify with a strict allowlist, not hand-written filters.
- Add a Content Security Policy that blocks inline event handlers.
- Mark session cookies HttpOnly so a script cannot read them directly.
- Review the admin side too. Moderation screens that render user content are prime targets.
Try it, then go further
Reading about it is the easy part. Open the lab, try your own approach before peeking at the spoiler, and see how many attempts it takes.
