CWE-352: Cross-Site Request Forgery (CSRF) - Java
Overview
CSRF vulnerabilities in Java web applications occur when state-changing endpoints don't verify that requests originated from the application itself. Spring Security enables CSRF protection by default, so a finding against a Spring application usually points at a configuration line that switched it off. Plain servlets and JAX-RS have no equivalent default, and need the check written by hand.
Primary Defence: Leave Spring Security's CSRF protection enabled, let the view layer emit the hidden _csrf field into forms, and keep state-changing operations on POST/PUT/DELETE so the CSRF filter validates them.
Common Vulnerable Patterns
Spring Boot with CSRF disabled
// VULNERABLE
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/admin/**").authenticated()
.anyRequest().permitAll()
.and()
.csrf().disable(); // CSRF protection disabled!
}
}
// VULNERABLE
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api")
public class TransferController {
@PostMapping("/transfer")
public ResponseEntity<String> transferFunds(
@RequestParam String toAccount,
@RequestParam BigDecimal amount,
Principal principal) {
// No CSRF validation - vulnerable to CSRF attacks
transferService.transfer(principal.getName(), toAccount, amount);
return ResponseEntity.ok("Transfer successful");
}
}
Why this is vulnerable: With .csrf().disable() in the filter chain, nothing checks where a POST came from. A form on any other site can target /api/transfer, and the browser attaches the victim's session cookie to it, so the transfer runs as the victim. The only thing the attacker needs is for a logged-in user to load their page.
Servlet without CSRF protection
// VULNERABLE
import jakarta.servlet.http.*;
import java.io.IOException;
@WebServlet("/transfer")
public class TransferServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute("userId") == null) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
// VULNERABLE - No CSRF token validation
String toAccount = request.getParameter("toAccount");
String amount = request.getParameter("amount");
Long userId = (Long) session.getAttribute("userId");
transferService.transfer(userId, toAccount, new BigDecimal(amount));
response.getWriter().write("Transfer successful");
}
}
Why this is vulnerable: The servlet checks that a session exists, which proves the user is logged in but says nothing about where the request came from. A form on an attacker's page that posts to /transfer travels with the victim's session cookie and passes that check, so the transfer completes while the victim is looking at the attacker's site.
JAX-RS without CSRF protection
// VULNERABLE
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
@Path("/account")
public class AccountResource {
@POST
@Path("/update-email")
@Produces(MediaType.APPLICATION_JSON)
public Response updateEmail(
@FormParam("email") String email,
@Context HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return Response.status(Response.Status.UNAUTHORIZED).build();
}
// VULNERABLE - Relies only on session cookie
Long userId = (Long) session.getAttribute("userId");
userService.updateEmail(userId, email);
return Response.ok().entity("{\"status\":\"updated\"}").build();
}
}
Why this is vulnerable: The resource authenticates on the session cookie alone. @FormParam means it accepts an ordinary HTML form post, which any site can send cross-origin, so an attacker's page can submit to /account/update-email and change the address on the victim's account without the victim seeing the request.
Secure Patterns
Spring Boot with CSRF protection
// SECURE
import org.springframework.boot.web.servlet.server.CookieSameSiteSupplier;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
)
.csrf(csrf -> csrf
// CSRF enabled by default, customize if needed
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringRequestMatchers("/api/webhook") // Only for webhooks with alternative auth
)
.sessionManagement(session -> session
.sessionFixation().newSession()
);
return http.build();
}
// Configure SameSite cookies
@Bean
public CookieSameSiteSupplier applicationCookieSameSiteSupplier() {
return CookieSameSiteSupplier.ofStrict();
}
}
// SECURE
import org.springframework.web.bind.annotation.*;
import org.springframework.security.core.Authentication;
@RestController
@RequestMapping("/api")
public class TransferController {
private final TransferService transferService;
public TransferController(TransferService transferService) {
this.transferService = transferService;
}
@PostMapping("/transfer")
public ResponseEntity<TransferResponse> transferFunds(
@RequestBody TransferRequest request,
Authentication authentication) {
// CSRF token automatically validated by Spring Security
String username = authentication.getName();
// Additional validation
if (request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
return ResponseEntity.badRequest().build();
}
TransferResponse response = transferService.transfer(
username,
request.getToAccount(),
request.getAmount()
);
return ResponseEntity.ok(response);
}
}
import java.math.BigDecimal;
public class TransferRequest {
private String toAccount;
private BigDecimal amount;
// Getters and setters
public String getToAccount() { return toAccount; }
public void setToAccount(String toAccount) { this.toAccount = toAccount; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
}
Why this works:
- Enabled by default: Validates all state-changing methods (POST/PUT/DELETE/PATCH) automatically via
CsrfFilterbefore controllers execute - Cookie-based tokens:
CookieCsrfTokenRepository.withHttpOnlyFalse()stores tokens inXSRF-TOKENcookie JavaScript can read; validates againstX-XSRF-TOKENheader using constant-time comparison - Defense-in-depth:
CookieSameSiteSupplier.ofStrict()addsSameSite=Strictto cookies, blocking cross-site transmission before validation - Zero-configuration controllers:
@PostMappinginherits CSRF validation from the security filter chain, so there is nothing per-controller left to forget - Proper exceptions:
ignoringRequestMatchers("/api/webhook")exempts one endpoint that authenticates another way (an HMAC signature) rather than the whole API;sessionFixation().newSession()prevents token capture
Thymeleaf form with CSRF token
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Transfer Funds</title>
</head>
<body>
<!-- CSRF token automatically included by Thymeleaf -->
<form th:action="@{/api/transfer}" method="post">
<input type="text" name="toAccount" required />
<input type="number" name="amount" step="0.01" required />
<button type="submit">Transfer</button>
</form>
</body>
</html>
Why this works:
- Automatic token injection: The
th:actionattribute puts a hidden_csrfinput into the rendered form, so nobody has to remember to add one - Framework integration: The token comes from the
HttpServletRequestattribute whereCsrfFilterplaced it, so the rendered form and the filter always agree - Zero-configuration: A form written with
th:actionis protected without further work, and one written with a plainactionstands out in review - XSS protection: The token is rendered into the HTML and never handed to JavaScript, which is less flexible for SPAs - those need the cookie-and-header approach below
JavaScript fetch with CSRF token
function getCsrfToken() {
// Spring Security sets CSRF token in cookie when using CookieCsrfTokenRepository
const name = 'XSRF-TOKEN';
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [cookieName, cookieValue] = cookie.trim().split('=');
if (cookieName === name) {
return decodeURIComponent(cookieValue);
}
}
return null;
}
// Make API call with CSRF token
async function transferFunds(toAccount, amount) {
const csrfToken = getCsrfToken();
const response = await fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': csrfToken // Spring expects X-XSRF-TOKEN header
},
credentials: 'same-origin',
body: JSON.stringify({
toAccount: toAccount,
amount: amount
})
});
if (!response.ok) {
throw new Error(`Transfer failed: ${response.status}`);
}
return await response.json();
}
Why this works:
- Cookie-to-header pattern: Reads the token from the
XSRF-TOKENcookie that Spring'sCookieCsrfTokenRepositoryset, and sends it back in theX-XSRF-TOKENheader - Same-origin protection: An attacker's page can neither read the victim's cookies nor set a custom header on a cross-site request, so it cannot reproduce that header
- SPA architecture support:
credentials: 'same-origin'sends auth cookies only for same-origin requests; the initial page load sets the token cookie for later API calls - Graceful failure: A missing or expired token returns 403 Forbidden, and the client reloads the page to pick up a fresh one
- Cookie security: Requires
httpOnly=falsefor JavaScript access, butSecureandSameSiteattributes provide defense-in-depth
Manual CSRF implementation with Servlet Filter
// SECURE manual implementation
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
public class CsrfFilter implements Filter {
private static final String CSRF_TOKEN_ATTR = "CSRF_TOKEN";
private static final String CSRF_HEADER = "X-CSRF-TOKEN";
private static final SecureRandom secureRandom = new SecureRandom();
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
HttpSession session = httpRequest.getSession();
String method = httpRequest.getMethod();
// Generate token for session if not exists
if (session.getAttribute(CSRF_TOKEN_ATTR) == null) {
String token = generateToken();
session.setAttribute(CSRF_TOKEN_ATTR, token);
}
// Validate CSRF token for state-changing requests
if ("POST".equals(method) || "PUT".equals(method) ||
"DELETE".equals(method) || "PATCH".equals(method)) {
String sessionToken = (String) session.getAttribute(CSRF_TOKEN_ATTR);
String requestToken = httpRequest.getHeader(CSRF_HEADER);
// Also check form parameter for traditional form submissions
if (requestToken == null) {
requestToken = httpRequest.getParameter("csrf_token");
}
if (sessionToken == null || requestToken == null ||
!MessageDigest.isEqual(
sessionToken.getBytes(StandardCharsets.UTF_8),
requestToken.getBytes(StandardCharsets.UTF_8))) {
httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN,
"CSRF token validation failed");
return;
}
}
chain.doFilter(request, response);
}
private String generateToken() {
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
@Override
public void init(FilterConfig filterConfig) {}
@Override
public void destroy() {}
}
<filter>
<filter-name>CsrfFilter</filter-name>
<filter-class>com.example.security.CsrfFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>CsrfFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
// SECURE with manual CSRF
@WebServlet("/transfer")
public class TransferServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
// CSRF validation already done by CsrfFilter
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute("userId") == null) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
String toAccount = request.getParameter("toAccount");
String amount = request.getParameter("amount");
Long userId = (Long) session.getAttribute("userId");
transferService.transfer(userId, toAccount, new BigDecimal(amount));
response.setContentType("application/json");
response.getWriter().write("{\"status\":\"success\"}");
}
@Override
protected void doGet(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
// Provide CSRF token to client
HttpSession session = request.getSession();
String csrfToken = (String) session.getAttribute("CSRF_TOKEN");
request.setAttribute("csrfToken", csrfToken);
request.getRequestDispatcher("/WEB-INF/transfer.jsp").forward(request, response);
}
}
Why this works:
- Framework-agnostic: A servlet filter gives you the Synchronizer Token Pattern in a legacy Java EE application, or anywhere Spring Security is not in play
- Cryptographic tokens:
SecureRandomwith 32 bytes (256 bits) makes the token unguessable; URL-safe Base64 keeps it intact in URLs, headers and form fields - Session binding: Storing the token in
HttpSessionties it to one authenticated user, so it cannot be reused across users and dies with the session - Hybrid support: Validates both the
X-CSRF-TOKENheader (AJAX) and thecsrf_tokenform parameter (traditional forms) - Production note: Prefer Spring Security when available; if implementing manually, compare token bytes with
MessageDigest.isEqual()and keep token lifetime/session binding explicit.
JAX-RS with CSRF protection
import jakarta.annotation.Priority;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.ws.rs.Priorities;
import jakarta.ws.rs.container.*;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@Provider
@Priority(Priorities.AUTHENTICATION)
public class CsrfTokenFilter implements ContainerRequestFilter {
@Context
private HttpServletRequest servletRequest;
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
String method = requestContext.getMethod();
// Only validate state-changing methods
if (!"POST".equals(method) && !"PUT".equals(method) &&
!"DELETE".equals(method) && !"PATCH".equals(method)) {
return;
}
HttpSession session = servletRequest.getSession(false);
if (session == null) {
requestContext.abortWith(
Response.status(Response.Status.UNAUTHORIZED).build()
);
return;
}
String sessionToken = (String) session.getAttribute("CSRF_TOKEN");
String requestToken = requestContext.getHeaderString("X-CSRF-TOKEN");
if (sessionToken == null || requestToken == null ||
!MessageDigest.isEqual(
sessionToken.getBytes(StandardCharsets.UTF_8),
requestToken.getBytes(StandardCharsets.UTF_8))) {
requestContext.abortWith(
Response.status(Response.Status.FORBIDDEN)
.entity("{\"error\":\"CSRF token validation failed\"}")
.build()
);
}
}
}
// SECURE
@Path("/account")
public class AccountResource {
@Inject
private UserService userService;
@POST
@Path("/update-email")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public Response updateEmail(
EmailUpdateRequest request,
@Context HttpServletRequest httpRequest) {
// CSRF validation done by CsrfTokenFilter
HttpSession session = httpRequest.getSession(false);
if (session == null) {
return Response.status(Response.Status.UNAUTHORIZED).build();
}
Long userId = (Long) session.getAttribute("userId");
userService.updateEmail(userId, request.getEmail());
return Response.ok()
.entity(new StatusResponse("updated"))
.build();
}
@GET
@Path("/csrf-token")
@Produces(MediaType.APPLICATION_JSON)
public Response getCsrfToken(@Context HttpServletRequest httpRequest) {
HttpSession session = httpRequest.getSession();
String token = (String) session.getAttribute("CSRF_TOKEN");
if (token == null) {
token = generateCsrfToken();
session.setAttribute("CSRF_TOKEN", token);
}
return Response.ok()
.entity(new CsrfTokenResponse(token))
.build();
}
private String generateCsrfToken() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}
Why this works:
- Framework-level filter:
@Providerregisters the filter with JAX-RS, and@Priority(Priorities.AUTHENTICATION)runs it before any resource method - Session binding: Storing tokens in
HttpSessioninherits the container's session management, clustering and timeout behaviour - REST endpoint for tokens: The
/csrf-tokenresource hands tokens to JavaScript clients, which is what an SPA needs since it renders no server-side form - Early request abort:
requestContext.abortWith()returns 403 Forbidden on a validation failure, so the resource method never runs - Production note: Prefer Spring Security when available; manual filters should use
MessageDigest.isEqual()for token comparison and explicit session/token lifetime handling.
Double Submit Cookie Pattern
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class CsrfDoubleSubmitFilter implements Filter {
private static final String CSRF_COOKIE = "XSRF-TOKEN";
private static final String CSRF_HEADER = "X-XSRF-TOKEN";
private static final String SECRET_KEY = System.getenv("CSRF_SECRET");
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String method = httpRequest.getMethod();
// The token is bound to this session, so the session has to exist
// before one can be issued or checked
HttpSession session = httpRequest.getSession(false);
String sessionId = (session == null) ? null : session.getId();
// Generate and set CSRF cookie if not present
Cookie[] cookies = httpRequest.getCookies();
String cookieToken = getCookieValue(cookies, CSRF_COOKIE);
if (cookieToken == null && sessionId != null) {
cookieToken = generateSignedToken(sessionId);
Cookie csrfCookie = new Cookie(CSRF_COOKIE, cookieToken);
csrfCookie.setPath("/");
csrfCookie.setSecure(true);
csrfCookie.setHttpOnly(false); // JavaScript needs to read
csrfCookie.setAttribute("SameSite", "Strict");
httpResponse.addCookie(csrfCookie);
}
// Validate for state-changing requests
if ("POST".equals(method) || "PUT".equals(method) ||
"DELETE".equals(method) || "PATCH".equals(method)) {
String headerToken = httpRequest.getHeader(CSRF_HEADER);
if (!validateTokens(cookieToken, headerToken, sessionId)) {
httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN,
"CSRF validation failed");
return;
}
}
chain.doFilter(request, response);
}
private String generateSignedToken(String sessionId) {
byte[] randomBytes = new byte[32];
new SecureRandom().nextBytes(randomBytes);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
String signature = sign(token, sessionId);
return token + "." + signature;
}
// The HMAC covers the session as well as the token, so a signed token is
// only valid for the session it was issued to. Signing the token alone
// would let an attacker who can write a cookie on this domain - from a
// sibling subdomain, a DNS takeover, or plaintext HTTP - replay a
// legitimate token of their own against another user.
private String sign(String value, String sessionId) {
try {
String message = sessionId.length() + "!" + sessionId + "!"
+ value.length() + "!" + value;
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(
SECRET_KEY.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
mac.init(secretKey);
byte[] hmac = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(hmac);
} catch (Exception e) {
throw new RuntimeException("Failed to sign token", e);
}
}
private boolean validateTokens(String cookieToken, String headerToken, String sessionId) {
if (cookieToken == null || headerToken == null || sessionId == null) {
return false;
}
// Verify cookie signature
String[] parts = cookieToken.split("\\.");
if (parts.length != 2) {
return false;
}
String token = parts[0];
String signature = parts[1];
String expectedSignature = sign(token, sessionId);
if (!MessageDigest.isEqual(
signature.getBytes(StandardCharsets.UTF_8),
expectedSignature.getBytes(StandardCharsets.UTF_8))) {
return false;
}
// Verify header matches cookie token
return MessageDigest.isEqual(
token.getBytes(StandardCharsets.UTF_8),
headerToken.getBytes(StandardCharsets.UTF_8));
}
private String getCookieValue(Cookie[] cookies, String name) {
if (cookies != null) {
for (Cookie cookie : cookies) {
if (name.equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
@Override
public void init(FilterConfig filterConfig) {}
@Override
public void destroy() {}
}
Why this works:
- No server-side token store: Validation needs only the HMAC secret and the session id, so no node has to remember which tokens it issued. The session is still required, because the signature binds the token to it
- HMAC signature security:
SecureRandomgenerates 256-bit tokens signed with HMAC-SHA256 underCSRF_SECRET, so a token cannot be forged without the secret - Dual validation: Verifies the signature with
MessageDigest.isEqual()(constant-time) and then matches the cookie against the header - an attacker can neither read the cookie (same-origin) nor set a custom header cross-site - Cookie security:
httpOnly=falsefor JavaScript access,secure=truefor HTTPS-only,SameSite=Strictfor cross-site blocking - Header-only validation: This filter reads the token from
X-XSRF-TOKENalone, so a plain form post needs JavaScript to attach the header; the session-bound filter above also accepts acsrf_tokenform parameter - Tradeoffs: Requires secret management/rotation; tokens don't auto-expire on logout - add timestamps for expiration validation
Common Pitfalls
ignoringRequestMatchers()scoped too broadly: Using.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))to unblock one webhook ends up exempting the entire API surface, not just the endpoint that genuinely uses an alternative authentication scheme.- Mixing
web.xmland annotation-based servlet registration in legacy apps: A CSRF filter's<url-pattern>declared inweb.xmlmay not match a servlet added purely via@WebServletscanning if the mapping wasn't also updated, leaving the new endpoint outside the filter's coverage. - A blanket
csrf().disable()for a token-authenticated API later gaining a cookie-based endpoint: Disabling CSRF is legitimate when authentication is a bearer token in a header (browsers don't auto-attach it, so CSRF doesn't apply). If a later feature adds cookie/session-based auth to the same Spring Security filter chain, the earlier blanket disable now also covers the new cookie-authenticated endpoints.