How to Prevent XSS in a Modern App

Frameworks removed most XSS by escaping output automatically. The remaining vulnerabilities live almost entirely in the places you told them not to.

How to Prevent XSS in a Modern App — Troiana insight cover

In short

Cross-site scripting happens when input becomes executable code in someone else's browser. The defence is escaping output according to the context it lands in — HTML, attribute, URL, or JavaScript each need different treatment. Modern frameworks do this automatically, so the remaining risk concentrates in the escape hatches: raw HTML insertion, URLs built from input, and inline event handlers.

What XSS actually is

Cross-site scripting is when data supplied by one person becomes executable code in another person's browser, inside your origin. Because it runs in your origin, it has access to their session, their cookies, and anything your page can do.

That is why it is more serious than it first sounds: an XSS bug in a logged-in area is usually equivalent to account takeover.

Three kinds

Stored. The payload is saved — a comment, a profile field, a support ticket — and served to everyone who views it. The most damaging, because one submission reaches many victims.

Reflected. The payload arrives in the request and is echoed straight back, typically through a search term or an error message. Requires getting a victim to follow a crafted link.

DOM-based. Never reaches the server. Client-side JavaScript reads something from the URL or storage and writes it into the page unsafely. Increasingly the common case, and invisible to server-side scanning.

The real defence is context-aware escaping

The single most important idea: escaping depends on where the value lands.

The same string needs different treatment in HTML body text, an HTML attribute, a URL, a <script> block, or a CSS value. Escaping for one context and inserting into another is how vulnerabilities survive in code that looks careful.

In practice this means: let your template engine or framework escape, and insert values into the context it expects. Do not build markup by concatenating strings, because concatenation is where context gets lost.

Where frameworks still leave gaps

Modern frameworks escape by default, so the vulnerabilities cluster in the deliberate exceptions.

Raw HTML insertion. dangerouslySetInnerHTML, v-html, innerHTML, and their equivalents. Every one bypasses escaping by design. If you need to render user HTML — a rich-text field, imported content — sanitise it with a maintained library rather than a regular expression. Sanitising HTML correctly is genuinely hard and repeatedly solved.

URLs built from input. A javascript: URL in an href executes on click, and escaping does not help because the string is syntactically fine. Validate that URLs begin with an expected scheme — https:, mailto: — rather than filtering for bad ones.

Attributes assembled dynamically, particularly style, onclick, and anything spread onto an element from a data object.

Client-side templating from location, document.referrer, postMessage, or storage. This is the DOM-based route, and it bypasses every server-side control.

Server-rendered configuration. Injecting a JSON blob into a <script> tag is a common pattern and needs escaping for the script context specifically — a closing tag inside a string ends the block early.

Content Security Policy

CSP is a second layer: even if a payload gets in, the browser refuses to execute it.

The valuable directive is script-src. Restricting it to your own origin, or to a nonce generated per response, prevents inline injected scripts from running.

Two practical points. unsafe-inline defeats most of the benefit, and it is what many sites end up with because inline scripts are convenient — a nonce-based policy is the version worth the effort. And CSP is defence in depth, not a substitute for escaping; treat it as the seatbelt rather than the brakes.

Roll it out in report-only mode first, collect violations for a couple of weeks, then enforce. Deploying a strict policy straight to production reliably breaks something.

Cookies and the blast radius

Since the main prize is the session, limit what a successful XSS can reach.

HttpOnly on session cookies means script cannot read them at all. This is why storing tokens in localStorage is a meaningful downgrade — any injected script can read it, as covered in sessions vs JWT.

SameSite limits cross-site request forgery, a related class of problem.

Re-authenticate for sensitive actions — changing email, password, or payment details — so a stolen session alone is not sufficient.

Testing for it

Try the obvious payloads in every input, then look at where they appear — including in attributes, in URLs, and in generated PDFs or emails, which people forget are also rendering contexts.

Search the codebase for the escape hatches. innerHTML, dangerouslySetInnerHTML, v-html, eval, and dynamic URL construction. That grep is usually the fastest audit available, because it enumerates exactly the places escaping was turned off.

Include stored inputs that render elsewhere — a name that appears in an admin panel, an email in an internal report. Fields rendered in a different application are a recurring blind spot.

Run automated scanning in CI, understanding it will not find DOM-based issues reliably.

The short version

Let the framework escape, and treat every escape hatch as a decision requiring justification. Sanitise HTML with a real library when you must accept it. Validate URL schemes rather than filtering. Keep session cookies HttpOnly. Add a nonce-based CSP as a second layer.

The grep for escape hatches is where to start on an unfamiliar codebase — it takes minutes and finds most of what is there.

If you are handling user-generated content and want the rendering paths reviewed, book a call.

Common questions

What is cross-site scripting?

When data supplied by one person becomes executable code in another person's browser inside your origin, giving it access to their session and cookies. In a logged-in area an XSS bug is usually equivalent to account takeover, which is why it is more serious than it initially sounds.

Do modern frameworks prevent XSS?

They prevent most of it by escaping output automatically, which is why remaining vulnerabilities concentrate in the deliberate escape hatches — dangerouslySetInnerHTML, v-html, innerHTML, URLs built from input, and client-side code writing values from the URL into the page.

How should I safely render user-submitted HTML?

Sanitise it with a maintained sanitisation library, never with a regular expression. Correctly sanitising HTML is genuinely hard and has been solved repeatedly; hand-rolled filtering reliably misses cases. If you can avoid accepting HTML at all, that is safer still.

Does a Content Security Policy stop XSS?

It is a second layer, not a replacement for escaping — even if a payload gets in, the browser refuses to execute it. The valuable part is a script-src restricted to your origin or to a per-response nonce. Note that unsafe-inline defeats most of the benefit, and roll any policy out in report-only mode first.

How do I audit a codebase for XSS quickly?

Search for the escape hatches: innerHTML, dangerouslySetInnerHTML, v-html, eval, and any dynamic URL construction. That grep enumerates exactly the places where automatic escaping was turned off, and it is usually the fastest useful audit on an unfamiliar codebase.

Have something worth building right?