Dunicot A cybersecurity consultancy and advisory firm.

Attack scenario · Healthcare

Stored XSS account takeover: one text box, every record that staff account can reach

Stored XSS: text saved in a patient message field runs inside the clinician console, drives the staff session and reads every record that account can reach.

High to Critical Stored cross-site scripting (persistent XSS)

At a glance

Formal class
Stored cross-site scripting, also called persistent XSS
Classification
CWE-79, OWASP Top 10 2021 category A03 Injection
Also searched as
persistent XSS, second-order XSS, blind XSS, XSS session hijacking
Reachable from
Any field one user submits that another user's browser later renders without encoding appropriate to that render context, including fields encoded correctly in one view and raw in another
Typical entry points
Patient messages, intake forms, appointment notes, file names, uploaded file content, profile fields
Who is affected
The staff member who opens the content, and every record their account reaches
Why it is underrated
No link for the victim to click and no compromise of the victim's credentials, so the attack produces no authentication failure against the targeted account, only a normal login to the attacker's own patient account if one is even required
Severity rating
High where script execution is confirmed in a second user's authenticated session with reach into patient data. Critical only where an administrative render point, unbounded record enumeration, self-propagation to further viewers or a confirmed account takeover is demonstrated. Markup that parses but cannot execute sits below both and is reported as markup injection

What it is

Stored cross-site scripting, also called persistent XSS, is a flaw where content one user submits is saved by the application and later rendered into another user's page as markup rather than as text. The second user's browser then parses it, and where no effective script policy stands in the way, executes it inside their own authenticated session. In a healthcare setting that second user is usually clinical or administrative staff, and the content arrives through a channel the organisation deliberately opened: a patient portal message, an intake form, an appointment request, a document description. The attacker needs no stolen password and no crafted link, because the application delivers the payload itself as part of normal work.

This class is routinely argued down to low severity on the assumption that XSS means a pop-up box, and the commercial reality is different. Script running in the application's own origin inherits the staff session, so it can read what that session can read and act as that session can act, which in a clinical console means patient records at whatever volume the server will serve them. Health data is special category data under UK and EU data protection law, and protected health information under HIPAA where the platform is a covered entity or a business associate. If the flaw is exploited, that access is unauthorised processing of the data, which puts the organisation into a breach risk assessment and probable notification, made harder by access logs that cannot scope which records were read. The injection on its own is not a notifiable breach. It is the condition that makes one reachable from a text field, with no authentication failure against the targeted account to raise an alert.

How the attack unfolds

Mechanism, not a recipe. Reproduction detail stays in client reports, because a page that hands a reader a working attack is a liability.

An outsider submits content that staff are expected to read

Healthcare platforms accept free text from people outside the organisation by design, through patient messaging, new-patient intake, referral notes, appointment requests, and the names and descriptions attached to uploaded documents. The attacker needs only whatever the public form or a patient login already grants. Input validation is the wrong place to stop this, and not because the content looks harmless: the safe form of a value depends on the sink it is later written into, one stored value is usually rendered into several different contexts, and blocklists are defeated by encoding, event-handler and non-script vectors, markup mutation, and the rich-text allowances the product grants on purpose.

The saved value is written into the clinician's page as markup, not text

When the console renders the record, the stored value is inserted into HTML without the encoding its destination requires, or handed to a client-side sink that parses HTML instead of assigning text. Once it is in the document the browser has no way to tell attacker content from application markup, so it parses it as markup. Persistence is what separates this from reflected XSS: there is nothing to deliver to the victim, who simply opens a record.

The script runs with the clinician's session, unless a script policy blocks it

Parsing is not yet execution, and the difference decides the severity. The injected script runs where there is no Content-Security-Policy on that route, where the policy retains unsafe-inline, or where it allow-lists a host that can be made to serve a script gadget. Where a nonce-based or hash-based policy with no unsafe-inline is genuinely enforced on the consuming route, the script is blocked and what remains is markup injection: dangling-markup capture of surrounding page content, form-action hijacking, a credential form injected into a trusted page, or a clickjacking overlay.

That is still a real finding at materially lower severity, and testing has to state which of the two outcomes it confirmed rather than assume the first.

What the injected code can do inside the application's own origin

The injected code runs inside the application's own origin, so the same-origin policy, the control that would stop an external site reading this data, never applies to it. Two capabilities follow without further conditions: the code can issue requests that the browser automatically attaches the session cookie to, and it can read the document, including any anti-CSRF token rendered into the page. Theft of the session artefact itself is conditional, and works only where the session is held in web storage or in a cookie without HttpOnly.

Where the session is a HttpOnly cookie there is nothing for script to read, and the attack proceeds by driving the live session in place rather than exporting it.

How much patient data moves depends on the victim role and the server

The code can read whatever the victim session is authorised to read, at the rate the server permits, and move it wherever the page's policy and the network allow. Volume therefore depends on what that role's listing and search endpoints return per request and whether per-account rate limits exist, and the exfiltration route depends on connect-src and form-action in the policy and on egress filtering at the network. A clinical role with a broad caseload and an unthrottled search endpoint is the case that turns one message into a bulk disclosure.

Testing reports the record count actually retrieved in controlled test accounts, not an implied unlimited set.

Why in-browser execution leaves the authentication logs looking normal

The two execution modes have very different detection profiles and should not be run together. Code executing inside the victim's own browser produces requests from a valid clinician session, in the browser that normally makes them, from the usual network, so authentication logs show a working day, and record-access logs commonly note which user opened which record without distinguishing a person from a script. That mode has to be caught on request volume and velocity per session, which is a signal many platforms do not monitor.

Replaying an exported session from attacker infrastructure is the opposite case: new IP, new network, changed user agent and device fingerprint, and impossible travel are exactly what session anomaly rules already look for. A finding should say which of the two it demonstrates.

The payload escalates with the next viewer, and can reinject itself

Escalation is conditional rather than automatic, and three things have to hold. The stored value has to be rendered unescaped in the privileged view as well, which is frequently a different template on a different escaping path. The sensitive actions have to be reachable without step-up authentication, because adding an account, issuing an API credential or changing a recovery address behind re-authentication stops the chain at that point.

Self-propagation additionally requires the viewing role to hold write access to a shared field, such as a message template, a triage queue entry or a staff directory record, and that write path to accept the markup. Escalation is reported where the privileged render point is confirmed unescaped in that role, and persistence where the rewrite is observed firing again for a second viewer.

Business impact

The unit of loss is not the single record the content was planted in, it is the entire reach of the account that renders it. Clinical and administrative roles are granted wide visibility on purpose, so one session commonly reaches a full caseload, a site's patient list, or the search endpoint that returns any of them. What is exposed is diagnoses, medication histories, test results, mental health and safeguarding notes, next-of-kin details and national identifiers, none of which can be reissued the way a card number can. Patients affected by this cannot change their medical history, so the harm does not expire.

Confirmed unauthorised access to health data moves the organisation into statutory notification. Under UK and EU GDPR a personal data breach must be reported to the supervisory authority without undue delay and, where feasible, within 72 hours of the organisation becoming aware of it, unless the breach is unlikely to result in a risk to the people affected. The individuals themselves must be told where that risk is high, and special category data makes that high-risk threshold easy to cross. A HIPAA covered entity must notify affected individuals and the Department of Health and Human Services, and give notice to prominent media where a breach affects more than 500 residents of a state or jurisdiction. The direct spend is incident response, external forensics, legal counsel, notification and support for affected individuals, and months of regulator correspondence. Alongside that sit contractual commitments to partner trusts, insurers and payers, and the questionnaire and renewal position in every enterprise deal that asks about prior breaches.

The operational cost is concentrated in a question that is hard to answer after the fact: which records a compromised clinician session would actually have read. Most application logging records legitimate access by a legitimate user, so a handful of records opened by the clinician and a bulk sweep driven by the script look the same in the log, and an organisation that cannot bound the exposure has to notify conservatively, which means telling people who were probably unaffected. Remediation also reaches past the code, because the injected content sits in the live database, in backups, and in everything derived from them such as exports, reports, data warehouses and generated PDF summaries. A code fix that does not also neutralise historic content at render time leaves stored values firing, and a restore from an untreated copy reintroduces them. In the meantime clinical teams lose the console they depend on, because a record view that cannot be trusted has to be worked around.

How it is found

What a tester looks for
SignalHow it is confirmed
Testing starts by mapping every path where one user's input reaches another user's screenWe map the features by principal rather than by page: patient to clinician, applicant to reviewer, supplier to procurement, device to dashboard. Each such field receives a unique benign marker, and we then open the consuming view in the staff role and inspect the rendered document. The bar has two stages. A marker parsed as markup rather than displayed as text confirms HTML injection and justifies escalating effort. Stored XSS is confirmed only by script execution observed in the consuming user's session, in a separate test account from the one that submitted the content, and evidenced from that session's own context. Where the marker parses but execution is blocked, we report markup injection at its own severity and name the control that held.
What matters is the context the value lands in, not whether a filter existsThe same stored value can be written into an HTML body, an attribute, a URL-bearing attribute such as href or formaction, inline script, an event-handler attribute, or a framework sink that accepts HTML. Encoding that is correct for one of those is wrong for another, and rich-text allowances reopen the sink deliberately. We establish which context applies at each render point and confirm the outcome from the resulting DOM rather than from the presence of a filter.
Stored XSS usually fires on a second-order render surface, away from the entry pointStored values are re-rendered somewhere other than where they were typed, including admin queues, audit trails, printed and PDF summaries, spreadsheet exports, clinical document viewers and email digests. We walk every consumer of the field in each role that can reach it, because a value encoded correctly in the patient's own view is frequently raw in the staff queue. The outcome is then labelled per consumer, because these are not all the same class. A browser render point can yield stored XSS. A server-side document renderer, such as a headless browser producing a PDF, yields server-side HTML injection, where the exposure is internal network reach, local file read or SSRF rather than a clinician session, and it is reported and fixed as such. A spreadsheet export yields formula injection. Markup in an email digest depends on the mail client and has to be demonstrated rather than assumed.
Confirmation means proving the session can be taken or drivenWe check how the session is held and whether the artefact is reachable from script, whether anti-CSRF tokens are readable from the page, what the Content-Security-Policy actually permits on that specific route, and what the victim role's listing endpoints return per request. Confirmation is demonstrated inside controlled test accounts: script in that origin reaching the authenticated API and retrieving a record the attacker-side test account has no right to see, with the number of records retrieved recorded rather than extrapolated.
Code review shows where output escaping has been switched offWe trace user-controlled data from request handlers to template and client render sinks, looking for raw-output helpers, HTML-assigning sinks, sanitisation applied on input instead of on output, and template partials shared between a trusted and an untrusted data path. Upload handling is reviewed on the same pass, because a user-supplied SVG or HTML file served from the application origin is a stored XSS vector that encoding a file name does not touch. Each candidate is then confirmed against the running application rather than reported from the code alone.

How it is fixed

Controls that hold
ControlWhat makes it hold
Encode on output in the encoding the destination needs, and treat URL and script sinks separatelyEscaping belongs at the point the value is written into a page, chosen for that sink, not at the point it is stored, because the safe form depends on the destination and one record is rendered into several destinations. Contextual encoding is necessary and it is not sufficient everywhere. HTML-encoding a value placed in href, src, action or formaction does not stop a javascript: or data: scheme, so those sinks need a scheme allow-list. No user-controlled data should be interpolated into inline script or an event-handler attribute at all. Client-side rendering should assign text through text-setting APIs and framework-safe bindings instead of HTML-parsing sinks. What makes it hold in practice is a template layer that escapes by default, with raw-output helpers treated as a reviewable exception with a named owner, so switching escaping off becomes a visible decision.
Enforce the safe sink in the toolchain, not in reviewer memoryTrusted Types, or an equivalent enforced-sink policy, prevents HTML-parsing sinks accepting a plain string at all, which is the durable fix for the client-side render path rather than a convention people have to remember. Alongside it, lint and CI gates should fail a build on raw-output helpers in templates and on HTML-assigning sinks in client code, with exceptions recorded rather than silent. This is the control that keeps a fixed codebase fixed as it changes hands.
Sanitise genuine rich text with a maintained allow-list library, and neutralise historic data on outputWhere staff need formatting, pass the content through a vetted sanitiser that works on a parsed document and permits an explicit list of elements and attributes, rather than a regular expression that blocks known-bad strings. Keep the library patched, because parser-differential and mutation XSS bypasses are fixed upstream. Content already in the database is best handled at render time, so stored rows do not have to be rewritten to be safe. Mutating stored clinical content, and editing backups in place especially, conflicts with record-integrity and retention obligations, destroys the evidence of what was actually submitted, and still leaves anything restored from an untouched copy dangerous. If stored data is remediated, run it as an audited migration that preserves the original value and the change record, and apply sanitisation on restore rather than to the backup.
Handle user-uploaded files as untrusted markup, not as file namesA user-supplied SVG, HTML or XML file served from the application origin executes in that origin when a staff member opens it, which encoding the file name does nothing about. Serve user-supplied files from a separate sandboxed origin, force a non-renderable Content-Type with Content-Disposition attachment and X-Content-Type-Options nosniff, and either reject SVG or pass it through the same sanitiser used for rich text.
A strict Content-Security-Policy as the second layerA nonce-based or hash-based script policy with no unsafe-inline, deployed on every route rather than on the main application shell alone, stops an injected inline script running when an encoding gap is missed. Tightening connect-src, form-action, img-src and frame-ancestors narrows the routes data can leave by and the overlays an injection can build. Treat it as a mitigation rather than a remedy, and keep it under test, because the policy is only as good as its weakest route and the injection is still sitting behind it.
Cap what any single session can pull, and gate the actions that matterA clinical account able to pull unbounded record sets is the multiplier that turns execution in one browser into a bulk disclosure, so cap and rate-limit record enumeration server-side and keep search scope tied to a legitimate care relationship. Require step-up authentication for account creation, credential issuance and recovery-address changes, and keep idle timeouts short. Holding the session in a HttpOnly cookie removes the script-readable token and the export path with it, while binding a session to an IP or device helps only against a session replayed from attacker infrastructure, because in-browser execution comes from the victim's own machine. None of this stops code acting inside a live session, which is why authorisation scoping is the load-bearing control here.
Logging that can bound a breach instead of widening itRecord-level access logging needs enough context to tell a human apart from a script: the originating view, request timing, sequence and volume per session, and the user agent. Alert on velocity and volume anomalies per account rather than only on failed logins, which this attack never produces against the targeted account. This is the control that turns an unscoped notification covering everybody into a scoped one covering only the records a session actually reached.

Questions

Is stored XSS actually serious or just a cosmetic bug?

Stored XSS is normally a high or critical finding, because it runs attacker-controlled code inside another user's authenticated session rather than merely altering a page. We rate it high where script execution is confirmed in a second user's session with reach into patient data, and critical only where an administrative render point, unbounded record enumeration, self-propagation to further viewers or a confirmed account takeover is demonstrated. The cosmetic reputation comes from self-XSS, where only the person pasting the payload is affected. Stored XSS is the reverse: the attacker submits once and other people's browsers execute it.

What is the difference between stored and reflected XSS?

Reflected XSS lives in the request, so the attacker has to get a victim to follow a crafted link, while stored XSS is saved by the application and served to whoever opens the affected record. That removes the delivery problem and the need for social engineering, and it means a single submission can reach many users over a long period.

Can cross-site scripting lead to account takeover?

Yes, where the application's own protections allow it. Code running in the application's origin can always read the document, including an anti-CSRF token rendered into the page, and send requests that the browser attaches the session cookie to, which is enough to act as the victim. Reading the session token itself is conditional on the session being held in web storage or in a cookie without HttpOnly. Changing an email address or adding a credential also requires those actions not to sit behind step-up authentication. Takeover needs the session and the ability to act within it, not the password.

Does setting HttpOnly on session cookies stop XSS?

No. HttpOnly stops script reading the cookie, which closes one exfiltration route, but it does not stop the injected code from acting. The browser still attaches the cookie to requests that code makes, so records can be read and actions performed from inside the session. HttpOnly limits the blast radius. It does not fix the injection.

Can a Content-Security-Policy stop stored XSS on its own?

No, not on its own. A strict nonce-based or hash-based policy with no unsafe-inline will usually prevent an injected inline script from executing, which is why it belongs in the design. It does not remove the injection, and policies are routinely worked around through a retained unsafe-inline, an allow-listed CDN that hosts a script gadget, a route that serves no policy at all, or exfiltration through a channel the policy never narrowed, such as a form action or an image request.

What is blind XSS, and how is it different from stored XSS?

Blind XSS is stored XSS whose payload fires in a view the submitter never sees, such as an admin queue, a support console, a log viewer or an internal dashboard. Nothing comes back at submission time, so the only confirmation is an out-of-band callback from the consuming browser. It matters because views that are never shown to outsiders are often the least hardened and the most privileged, so the same injection lands in a higher-value session than the attacker could reach directly.

Is a stored XSS exploit a reportable breach under HIPAA or GDPR?

It can be. If patient data was accessed through the injected script, the duty to notify is triggered by the unauthorised access itself, not by the technique used. GDPR requires a report to the supervisory authority without undue delay and, where feasible, within 72 hours of becoming aware, unless the breach is unlikely to result in a risk to the people affected, and HIPAA requires notice to affected individuals and to HHS. An unexploited injection is not itself notifiable. The difficult part is evidencing which records were read.

Services: where this is tested

Test for this on your stack

Describe the platform and the deadline. A fixed quote follows a short scoping call.