Prevent XSS in Spring Boot 3 → CSP & Sanitization (2025)





Prevent XSS in Spring Boot 3 → CSP & Sanitization (2025) | www.codegigs.app





Prevent XSS in Spring Boot 3 (Thymeleaf Isn’t Enough)

I used to think Cross-Site Scripting (XSS) was a solved problem. “Just use Thymeleaf,” they said. “It escapes everything by default.”

Then I inherited a legacy app where a developer used utext because they wanted to bold a username. Next thing I knew, we had a stored XSS vulnerability that hit 500 users in an hour. At www.codegigs.app, I see this constantly: developers rely on one layer of defense, and when that layer cracks, it’s game over.

Here’s how to actually fix XSS in Spring Boot using a defense-in-depth strategy that works.

The One-Line Fix: Content Security Policy (CSP)

If you take nothing else from this post, do this. A Content Security Policy (CSP) tells the browser: “Only run scripts from these specific domains. Ignore everything else.”

Even if an attacker manages to inject <script>alert('hacked')</script> into your page, the browser will look at your CSP header, see that inline scripts aren’t allowed, and block it.

Here is the production config I use for Spring Security 6.2+:

// SecurityConfig.java
// Spring Boot 3.2+
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .headers(headers -> headers
            .contentSecurityPolicy(csp -> csp
                // The policy string:
                // 1. default-src 'self' -> Only load resources from my own domain
                // 2. script-src 'self' https://trusted.cdn.com -> Allow JS from me + CDN
                // 3. object-src 'none' -> Block Flash/Plugins (obvious)
                .policyDirectives("default-src 'self'; script-src 'self' https://trusted.cdn.com; object-src 'none'")
            )
        );
        
    return http.build();
}

Line 9 is your safety net. By setting script-src to specific domains, you kill 99% of XSS attacks instantly.

This is different from CSRF protection (which stops unauthorized actions) — CSP stops unauthorized execution.

Why Thymeleaf Escaping Fails

Thymeleaf is great. By default, it uses th:text, which escapes HTML characters.

Safe:

<p th:text="${userInput}"></p>

The Trap:

The moment you need to render actual HTML (like a blog post or a comment with bold text), you’ll be tempted to use th:utext (Unescaped Text). Do NOT do this with user input unless you sanitize it first.

<div th:utext="${blogPostContent}"></div>

If you absolutely must render user-generated HTML, you need to scrub it clean. That brings us to Sanitization.

Sanitizing HTML (The Right Way)

Don’t write your own regex to strip tags. You will fail. A user on Reddit recently showed how easily regex filters are bypassed with hex encoding or malformed tags.

Use the OWASP Java HTML Sanitizer. It’s built by Google and it’s battle-tested.

1. Add the dependency:

<dependency>
    <groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
    <artifactId>owasp-java-html-sanitizer</artifactId>
    <version>20240325.1</version>
</dependency>

2. Create a Sanitization Service:

// HtmlSanitizerService.java
import org.owasp.html.PolicyFactory;
import org.owasp.html.Sanitizers;
import org.springframework.stereotype.Service;

@Service
public class HtmlSanitizerService {

    // Define the policy once (thread-safe)
    private final PolicyFactory policy = Sanitizers.FORMATTING
        .and(Sanitizers.LINKS)
        .and(Sanitizers.BLOCKS)
        .and(Sanitizers.IMAGES);

    public String sanitize(String input) {
        if (input == null) return null;
        return policy.sanitize(input);
    }
}

Now, even if you use th:utext, you pass the data through sanitize() first. It strips out <script>, <iframe>, and onclick attributes but keeps <b>, <i>, and <p>.

Pro Tip: Never sanitize data before saving it to the database. Sanitize it on the way out (when rendering). If you sanitize on save, and your sanitization logic changes later, you’ve permanently destroyed the original data.

API Security: It’s Not Just HTML

If you’re building a REST API, you might think you’re safe because “JSON isn’t executable.” Wrong.

If you serve JSON with the wrong content type, older browsers might try to sniff it as HTML. Or, if your React/Angular frontend blindly renders that JSON into the DOM (using dangerouslySetInnerHTML), you have XSS.

Ensure your APIs always return the correct header. Spring Boot does this automatically for @RestController, but verify it:

// Enforce JSON content type
@GetMapping(value = "/api/comments", produces = MediaType.APPLICATION_JSON_VALUE)
public List<Comment> getComments() {
    return commentRepository.findAll();
}

This ensures the response sends Content-Type: application/json. Combined with the X-Content-Type-Options: nosniff header (which Spring Security adds by default), this prevents the browser from executing the response.

Common Mistakes I See

  1. Disabling defaults: I’ve seen devs do .headers(h -> h.disable()) because of a CORS error. This turns off XSS protection headers. Don’t do it.
  2. Allowing ‘unsafe-inline’: If your CSP has script-src ‘unsafe-inline’, it’s useless. Move your inline JS to external files.
  3. Ignoring Logs: If you don’t enable security logging, you won’t know when someone is probing your defenses.

Is Your App Actually Secure?

XSS is just one vector. What about Broken Access Control? Or JWT theft?

At www.codegigs.app, we cover the full security lifecycle in our Spring Security Master Class.

Start the Course

Summary

XSS prevention isn’t about one magic setting. It’s a stack:

  • Layer 1: Automatic escaping (Thymeleaf th:text).
  • Layer 2: Input sanitization for rich text (OWASP Sanitizer).
  • Layer 3: Content Security Policy (CSP) to block execution if layers 1 & 2 fail.

Once you’ve locked this down, check out our next guide on Mastering OAuth2 to handle identity properly.


Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top