Skip to content

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 Framework provides robust CSRF protection out of the box, but it must be properly configured. Legacy frameworks may require manual implementation.

Primary Defence: Enable Spring Security's CSRF protection (enabled by default), use @CsrfToken in forms, and ensure state-changing operations use POST/PUT/DELETE with CSRF token validation.

Common Vulnerable Patterns

Spring Boot with CSRF disabled

SecurityConfig.java
// 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!
    }
}
TransferController.java
// 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: Disabling CSRF protection with .csrf().disable() allows attackers to craft malicious websites that submit authenticated POST requests using the victim's session cookie, enabling unauthorized fund transfers, account changes, or data modifications without user consent.

Servlet without CSRF protection

TransferServlet.java
// VULNERABLE
import javax.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: Without CSRF token validation, attackers can create malicious forms on external websites that POST to this servlet using the victim's authenticated session, allowing unauthorized transfers or actions to be performed silently when victims visit the malicious site.

JAX-RS without CSRF protection

AccountResource.java
// VULNERABLE
import javax.ws.rs.*;
import javax.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: JAX-RS endpoints without CSRF token validation rely solely on session cookies for authentication, allowing attackers to craft malicious forms that submit to these endpoints using victims' authenticated sessions, enabling unauthorized email changes or other state-changing operations.

Secure Patterns

Spring Boot with CSRF protection

SecurityConfig.java
// SECURE
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();
}
TransferController.java
// 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);
    }
}
TransferRequest.java
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 CsrfFilter before controllers execute
  • Cookie-based tokens: CookieCsrfTokenRepository.withHttpOnlyFalse() stores tokens in XSRF-TOKEN cookie JavaScript can read; validates against X-XSRF-TOKEN header using constant-time comparison
  • Defense-in-depth: CookieSameSiteSupplier.ofStrict() adds SameSite=Strict to cookies, blocking cross-site transmission before validation
  • Zero-configuration controllers: @PostMapping automatically inherits CSRF validation from security filter chain, preventing developers from forgetting protection
  • Proper exceptions: ignoringRequestMatchers("/api/webhook") demonstrates excluding endpoints with alternative auth (HMAC signatures); sessionFixation().newSession() prevents token capture

Thymeleaf form with CSRF token

transfer.html
<!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: th:action attribute triggers automatic insertion of hidden _csrf input field in all forms, eliminating developer error
  • Framework integration: Extracts current CSRF token from HttpServletRequest attribute where CsrfFilter places it after generation, ensuring consistency
  • Zero-configuration: Using Thymeleaf forms correctly (th:action) automatically provides protection; omitting it is visible code smell during reviews
  • XSS protection: Server-side rendering ensures tokens never exposed in JavaScript, though less flexible for dynamic SPAs (which need JavaScript/cookie approach)

JavaScript fetch with CSRF token

csrf-utils.js
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 token from XSRF-TOKEN cookie (set by Spring's CookieCsrfTokenRepository) and includes in X-XSRF-TOKEN header for AJAX
  • Same-origin protection: Attackers cannot read victim cookies or set custom headers cross-site due to browser same-origin policy
  • SPA architecture support: credentials: 'same-origin' sends auth cookies only for same-origin requests; initial page load sets token cookie for subsequent API calls
  • Graceful failure: Missing/expired token returns 403 Forbidden, prompting page refresh for new token
  • Cookie security: Requires httpOnly=false for JavaScript access, but Secure and SameSite attributes provide defense-in-depth

Manual CSRF implementation with Servlet Filter

CsrfFilter.java
// SECURE manual implementation
import javax.servlet.*;
import javax.servlet.http.*;
import java.io.IOException;
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 || !sessionToken.equals(requestToken)) {
                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() {}
}
web.xml
<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>
TransferServlet.java
// 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: Servlet filter provides Synchronizer Token Pattern for legacy Java EE apps or scenarios without Spring Security
  • Cryptographic tokens: SecureRandom with 32 bytes (256 bits) ensures unpredictability; URL-safe Base64 encoding for safe transmission in URLs/headers/forms
  • Session binding: HttpSession storage ties tokens to authenticated users, preventing cross-user reuse and ensuring expiration with session end
  • Hybrid support: Validates both X-CSRF-TOKEN header (AJAX) and csrf_token form parameter (traditional forms)
  • Production warning: Uses equals() (vulnerable to timing attacks) - should use MessageDigest.isEqual() for constant-time comparison; prefer Spring Security when available

JAX-RS with CSRF protection

CsrfTokenFilter.java
import javax.annotation.Priority;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import javax.ws.rs.Priorities;
import javax.ws.rs.container.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.Provider;
import java.io.IOException;

@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 || !sessionToken.equals(requestToken)) {
            requestContext.abortWith(
                Response.status(Response.Status.FORBIDDEN)
                    .entity("{\"error\":\"CSRF token validation failed\"}")
                    .build()
            );
        }
    }
}
AccountResource.java
// 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: @Provider registers filter with JAX-RS; @Priority(Priorities.AUTHENTICATION) validates CSRF before business logic executes
  • Session binding: Stores tokens in HttpSession, leveraging Java EE session management, clustering, and timeout for enterprise applications
  • REST endpoint for tokens: /csrf-token resource provides tokens to JavaScript clients, supporting SPA architectures
  • Early request abort: requestContext.abortWith() returns 403 Forbidden immediately on validation failure, preventing business logic execution
  • Production note: Use MessageDigest.isEqual() instead of equals() for constant-time comparison to prevent timing attacks
CsrfDoubleSubmitFilter.java
import javax.servlet.*;
import javax.servlet.http.*;
import java.io.IOException;
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();

        // Generate and set CSRF cookie if not present
        Cookie[] cookies = httpRequest.getCookies();
        String cookieToken = getCookieValue(cookies, CSRF_COOKIE);

        if (cookieToken == null) {
            cookieToken = generateSignedToken();
            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)) {
                httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, 
                    "CSRF validation failed");
                return;
            }
        }

        chain.doFilter(request, response);
    }

    private String generateSignedToken() {
        byte[] randomBytes = new byte[32];
        new SecureRandom().nextBytes(randomBytes);
        String token = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
        String signature = sign(token);
        return token + "." + signature;
    }

    private String sign(String value) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKey = new SecretKeySpec(SECRET_KEY.getBytes(), "HmacSHA256");
            mac.init(secretKey);
            byte[] hmac = mac.doFinal(value.getBytes());
            return Base64.getUrlEncoder().withoutPadding().encodeToString(hmac);
        } catch (Exception e) {
            throw new RuntimeException("Failed to sign token", e);
        }
    }

    private boolean validateTokens(String cookieToken, String headerToken) {
        if (cookieToken == null || headerToken == 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);

        if (!MessageDigest.isEqual(signature.getBytes(), expectedSignature.getBytes())) {
            return false;
        }

        // Verify header matches cookie token
        return MessageDigest.isEqual(token.getBytes(), headerToken.getBytes());
    }

    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:

  • Stateless scaling: No server sessions - cookies only, ideal for distributed/load-balanced systems without session affinity
  • HMAC signature security: SecureRandom generates 256-bit tokens signed with HMAC-SHA256 using CSRF_SECRET, preventing forgery without the secret
  • Dual validation: Verifies signature using MessageDigest.isEqual() (constant-time) and matches cookie/header - attackers can't read cookies (same-origin) or set custom headers cross-site
  • Cookie security: httpOnly=false for JavaScript access, secure=true for HTTPS-only, SameSite=Strict for cross-site blocking
  • Hybrid support: Works for forms (hidden field) and AJAX (header)
  • Tradeoffs: Requires secret management/rotation; tokens don't auto-expire on logout - add timestamps for expiration validation

Verification

After implementing the recommended secure patterns, verify the fix through multiple approaches:

  • Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
  • Code review: Confirm all instances use the secure pattern (parameterized queries, safe APIs, proper encoding) with no string concatenation or unsafe operations
  • Static analysis: Use security scanners to verify no new vulnerabilities exist and the original finding is resolved
  • Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
  • Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
  • Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
  • Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
  • Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced

Additional Resources