Understanding XSS Attacks in Modern Web Apps

Hoang Vu,5 min read

March 25, 2026

Cross-Site Scripting (XSS) happens when untrusted input is interpreted as executable code in a browser. If attackers can inject script into a page, they may steal session data, perform actions on behalf of users, or alter UI to phish credentials.

This article is inspired by Vercel security guidance and translated into practical React/Next.js implementation patterns.

Loading XSS remotion demos...

1. How an XSS attack usually unfolds

A common attack chain:

  1. Attacker finds a place where user input is rendered unsafely.
  2. Attacker injects a malicious payload.
  3. Victim opens the affected page.
  4. Payload executes in victim browser under your site's origin.
  5. Sensitive data (token, session context) is exfiltrated or actions are triggered.

What makes XSS dangerous is that the code runs as if it belongs to your trusted app.

Deep dive: execution context is the core risk

When malicious JavaScript executes under your origin, browser security boundaries work for the attacker, not for you. The script can often:

  1. read page-level data (including sensitive state rendered in HTML),
  2. call internal APIs as the current user,
  3. trigger actions in the UI invisibly,
  4. rewrite forms or modals to harvest credentials.

This is why XSS often escalates from "just one vulnerable field" to account-level compromise.

XSS ATTACK CHAIN

1. Find vulnerable sink
2. Inject malicious payload
3. Victim loads page
4. Script executes in origin
5. Data exfiltration/action abuse

2. Main XSS categories you need to know

DOM-based XSS

The vulnerability exists fully in frontend code, usually due to dangerous sinks such as innerHTML, outerHTML, insertAdjacentHTML, or unsafe URL handling.

Typical source-to-sink pattern:

  1. source: location.search, location.hash, postMessage payload, or third-party widget data,
  2. sink: DOM API that parses HTML/JS-sensitive content,
  3. result: browser executes attacker-controlled markup or script.

Reflected XSS

Payload comes from a request parameter and is reflected immediately in response HTML/JS context.

Reflected XSS is often used in phishing chains because the payload can be packed into a URL and distributed quickly.

Stored XSS

Payload is persisted (for example comment, profile, CMS field) and served to many users later. This is usually the most severe because it scales automatically.

Stored XSS is especially dangerous in admin-facing surfaces: if privileged users view infected content, the attacker may jump from one low-privilege account to full control.

XSS TYPES MATRIX

DOM XSS

Client-side sink misuse

Reflected XSS

Payload reflected in response

Stored XSS

Persistent payload from storage


3. Typical vulnerable patterns (and safer alternatives)

Anti-pattern

// Unsafe: attacker-controlled string interpreted as HTML
<div dangerouslySetInnerHTML={{ __html: userContent }} />

Better pattern

// Safe by default: React escapes values rendered in JSX text context
<div>{userContent}</div>

If rich HTML is truly required

import DOMPurify from "isomorphic-dompurify";
 
const sanitized = DOMPurify.sanitize(userContent);
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;

Rule of thumb: do not switch to raw HTML rendering unless business requirements force it.

Deep dive: context matters more than one sanitizer call

Sanitization helps, but context-aware output handling is still required:

  1. HTML context: escape/sanitize markup.
  2. Attribute context: validate protocols and allowed values.
  3. URL context: block javascript: and malformed schemes.
  4. Script context: avoid embedding untrusted values directly.

A lot of production XSS issues happen when teams sanitize once, then later reuse the same value in a different context where assumptions no longer hold.


4. Input validation: client helps UX, server enforces security

Client-side validation improves form experience, but it is not a trust boundary.

Server-side should always:

  1. validate schema and data type,
  2. normalize and constrain allowed formats,
  3. sanitize where HTML is accepted,
  4. encode output according to rendering context.

In production systems, this should happen at every write path, not only at UI forms.

Practical backend pattern

For user-generated content pipelines, split processing into stages:

  1. schema validation (shape/type),
  2. normalization (length, allowed tags/attributes),
  3. sanitization and persistence,
  4. safe rendering policy at read-time.

This avoids the "validated in one endpoint, bypassed in another" problem.


5. Cookie and session hardening against XSS impact

XSS prevention is primary defense, but blast-radius reduction is also essential:

  1. keep session cookies HttpOnly and Secure,
  2. prefer SameSite=Lax or Strict where possible,
  3. avoid storing long-lived auth secrets in localStorage,
  4. rotate session and refresh tokens.

HttpOnly does not stop XSS itself, but it can prevent direct cookie reads via JavaScript.

Also remember: even with HttpOnly, attacker scripts may still perform authenticated actions from the victim browser. So cookie flags reduce impact but do not replace XSS prevention.


6. CSP is your containment layer

A strong Content Security Policy (CSP) can significantly reduce exploitability.

Useful principles:

  1. disallow inline scripts where possible,
  2. use nonce/hash-based policies for approved scripts,
  3. restrict script sources to trusted origins,
  4. monitor CSP violation reports.

CSP is not a replacement for output encoding and sanitization, but it limits damage when bugs slip through.

CSP deployment notes teams often miss

  1. Start with report-only mode to measure breakage.
  2. Move to enforcing mode with nonces/hashes.
  3. Keep policy close to build/runtime ownership so drift is visible.
  4. Treat new third-party scripts as a security review event.

XSS DEFENSE LAYERS

Defense in depth
Input validation
Output encoding
HTML sanitization
CSP policy
Cookie hardening
Dependency audit

7. Practical checklist for React/Next.js teams

  1. Default to JSX escaping; avoid raw HTML rendering.
  2. Sanitize any user-generated HTML with a trusted sanitizer.
  3. Remove inline event handlers and inline script blocks.
  4. Validate and sanitize on server before persistence.
  5. Set HttpOnly, Secure, and appropriate SameSite cookie attributes.
  6. Add CSP with nonces/hashes and restricted script sources.
  7. Review third-party libraries/components that touch DOM directly.
  8. Add security tests for dangerous sinks (innerHTML, URL injection paths).

8. Conclusion

XSS is still one of the most practical web attack vectors because it abuses trust in your own origin context. The strongest strategy is layered:

  • strict input handling,
  • safe output rendering,
  • CSP and cookie hardening,
  • secure defaults in component design.

If you implement these systematically, XSS moves from a recurring production risk to a controlled engineering concern.

Security maturity here is less about one-time fixes and more about engineering discipline: safe defaults, reviewable rendering boundaries, and continuous verification in CI and runtime.

2026 © @hoag/blog.