Depois que escrevi sobre CSRF no Laravel, veio a pergunta óbvia: e no Spring, tem isso? Tem. E a resposta curta é que o Spring Security protege contra CSRF por padrão, exatamente como o Laravel. Você não precisa escrever nenhuma linha pra isso funcionar.
A resposta longa é mais interessante, porque o Spring resolve o mesmo problema com duas peças a mais que o Laravel não tem, e são justamente essas duas peças que quebram a cabeça de quem está montando uma SPA.
#Relembrando o ataque em uma frase
Se você não leu o post anterior, o resumo é esse: o navegador anexa o cookie de sessão em toda requisição para um domínio, não importa de qual site ela partiu. Então um site malicioso consegue disparar um POST para a sua aplicação, e o servidor recebe uma requisição perfeitamente autenticada que o usuário nunca quis fazer.
A defesa, em qualquer framework, é a mesma ideia: exigir em toda requisição que altera estado um token secreto que só a sua aplicação conhece e que o site do atacante não tem como ler.
#Quem faz o trabalho no Spring
No Laravel, o middleware é o VerifyCsrfToken. No Spring Security, é o CsrfFilter, que já entra na filter chain assim que você adiciona o spring-boot-starter-security no projeto.
Você pode declarar a configuração padrão explicitamente, mas ela é exatamente o que já acontece sem você fazer nada:
1@Configuration
2@EnableWebSecurity
3public class SecurityConfig {
4
5 @Bean
6 public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
7 http
8 // ...
9 .csrf(Customizer.withDefaults());
10
11 return http.build();
12 }
13}
O CsrfFilter faz basicamente três coisas: verifica se a requisição precisa de proteção, carrega o token esperado, e compara com o token que o cliente mandou. Igual ao Laravel, a verificação só vale para métodos que alteram estado. GET, HEAD, TRACE e OPTIONS passam direto.
Quando o token não bate ou não vem, o Spring lança uma InvalidCsrfTokenException (ou MissingCsrfTokenException), que são subclasses de AccessDeniedException. Na prática, você recebe um 403 Forbidden. É o "419 Page Expired" do Laravel com outro número na frente.
#Onde o token fica guardado
O Laravel guarda o token na sessão e ponto. O Spring te dá a escolha através da interface CsrfTokenRepository.
O padrão é o HttpSessionCsrfTokenRepository: token na HttpSession, mesma estratégia do Laravel.
A alternativa é o CookieCsrfTokenRepository, que grava o token num cookie chamado XSRF-TOKEN e o lê de volta do header X-XSRF-TOKEN ou do parâmetro _csrf. Esses nomes vêm do Angular, que já os envia automaticamente:
1http
2 .csrf((csrf) -> csrf
3 .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
4 );
Repara no withHttpOnlyFalse(). Ele é necessário justamente pra que o JavaScript consiga ler esse cookie. Se a sua aplicação não precisa disso, use new CookieCsrfTokenRepository() e deixe o cookie como HttpOnly.
Aqui vale uma pausa, porque a pergunta aparece sempre: guardar o token num cookie não anula a proteção, já que o navegador manda cookie sozinho? Não. O ataque continua barrado porque o atacante precisa ler o valor do cookie pra copiá-lo no header, e a Same-Origin Policy impede que ele leia cookies do seu domínio. O navegador até envia o cookie sozinho, mas não envia o header. E é o header que o filtro compara.
#No formulário
No Laravel você escreve @csrf. No Spring, se você usa Thymeleaf, não escreve nada:
1<form th:action="@{/perfil}" method="post">
2 <input type="text" name="nome">
3 <button>Salvar</button>
4</form>
Usando th:action, o Thymeleaf injeta sozinho o campo escondido:
1<input type="hidden" name="_csrf" value="4bfd1575-3ad1-4d21-96c7-4ef2d9f86721">
Isso funciona porque o Thymeleaf se integra com o RequestDataValueProcessor do Spring, e o Spring Security fornece a implementação CsrfRequestDataValueProcessor. A form tag library do Spring MVC faz o mesmo.
O detalhe que pega gente desatenta: a mágica só acontece com th:action. Se você escrever action="/perfil" na mão, o Thymeleaf não tem como interceptar nada, e o seu POST vai tomar 403.
Se a sua view não tiver essa integração, o token está exposto como um atributo da request chamado _csrf, e você o insere manualmente:
1<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
#A primeira peça a mais: proteção contra BREACH
Aqui o Spring passa a divergir do Laravel.
A partir do Spring Security 6, o handler padrão é o XorCsrfTokenRequestAttributeHandler. O que ele faz é embaralhar o token com um valor aleatório antes de renderizá-lo, de forma que o valor entregue na resposta muda a cada requisição, mesmo que o token persistido na sessão continue o mesmo. Quando o token volta, ele é decodificado e comparado com o valor real.
O motivo é o BREACH, um ataque que explora compressão HTTP: observando o tamanho de respostas comprimidas, dá pra ir adivinhando um segredo que se repete no corpo delas. Se o token nunca muda, ele é um alvo estável. Com a randomização, não é.
O Laravel não faz isso. E na maior parte do tempo essa diferença é invisível — até você tentar montar uma SPA.
#A segunda peça a mais: carregamento adiado
O Spring Security 6 também passou a carregar o token de forma adiada (deferred). A ideia é performance: se a requisição não precisa de verificação, não faz sentido tocar na sessão só pra montar um token que ninguém vai usar.
O efeito colateral é que, se nada na sua requisição "puxar" o token, ele nunca é gerado — e o cookie XSRF-TOKEN que a sua SPA está esperando simplesmente não aparece na resposta.
#Juntando as duas: por que a SPA quebra
Agora dá pra entender o problema clássico. Sua SPA lê o cookie XSRF-TOKEN e manda o valor no header X-XSRF-TOKEN. Mas o cookie contém o token cru, enquanto o handler padrão espera receber o valor embaralhado. Resultado: 403 em toda requisição, com um token que parece certíssimo quando você olha no DevTools.
Some a isso o carregamento adiado e o fato de que o CsrfAuthenticationStrategy e o CsrfLogoutHandler limpam o token depois do login e do logout — ou seja, logo depois de autenticar, sua SPA fica sem cookie válido justamente quando vai começar a fazer POSTs.
No Spring Security 6, a receita oficial é um handler híbrido: se o token veio num header, resolve como valor cru; se veio como parâmetro de formulário, resolve como valor embaralhado. E força o carregamento do token pra que o cookie sempre saia na resposta.
1final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
2
3 private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler();
4 private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler();
5
6 @Override
7 public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
8 this.xor.handle(request, response, csrfToken);
9 // força o token adiado a ser carregado, garantindo o cookie na resposta
10 csrfToken.get();
11 }
12
13 @Override
14 public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
15 String headerValue = request.getHeader(csrfToken.getHeaderName());
16 return (StringUtils.hasText(headerValue) ? this.plain : this.xor)
17 .resolveCsrfTokenValue(request, csrfToken);
18 }
19}
E na configuração:
1http
2 .csrf((csrf) -> csrf
3 .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
4 .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
5 );
Se você está no Spring Security 7, esse bloco inteiro virou uma linha:
1http.csrf((csrf) -> csrf.spa());
Guarde essa: é o tipo de coisa que custa uma tarde inteira de depuração pra quem não sabe que a randomização existe.
#E numa aplicação multipágina?
Se o seu front é JavaScript espalhado sobre páginas renderizadas no servidor, você não precisa de cookie nenhum. Dá pra jogar o token em meta tags, do mesmo jeito que se faz no Laravel:
1<meta name="_csrf" th:content="${_csrf.token}">
2<meta name="_csrf_header" th:content="${_csrf.headerName}">
E lê de lá na hora de montar a requisição. Como o token vai renderizado no HTML pelo handler padrão, ele já sai embaralhado corretamente, e o problema da seção anterior nem existe.
#E as APIs? Precisam de token CSRF?
Mesma resposta do Laravel: APIs stateless autenticadas por token — JWT, OAuth2 com bearer token — não precisam. O ataque depende de o navegador anexar a credencial sozinho, e ele não faz isso com o header Authorization.
A diferença é que o Laravel já separa isso pra você, deixando as rotas de API fora do middleware web. No Spring, é você quem decide:
1http.csrf((csrf) -> csrf.disable());
Só que disable() desliga tudo. Se a sua aplicação serve as duas coisas — páginas com sessão por cookie e uma API com bearer token — desligar de forma global abre um buraco real na parte que usa sessão. Nesse caso, o certo é isentar apenas o que precisa:
1http.csrf((csrf) -> csrf
2 .ignoringRequestMatchers("/api/**")
3);
Ou, melhor ainda, separar em duas SecurityFilterChain com securityMatcher, uma para a API e outra para o app web. Aí cada uma carrega a política que faz sentido pra ela.
Vale dizer: csrf.disable() é provavelmente a linha mais copiada e colada do ecossistema Spring. Ela aparece em todo tutorial de "como fazer login com JWT", e muita gente leva ela pra dentro de aplicações que continuam usando sessão. Se você tem JSESSIONID no navegador, não desligue.
#Laravel x Spring, lado a lado
Fechando o paralelo entre os dois frameworks:
- Ligado por padrão: nos dois. Nem Laravel nem Spring Security exigem configuração pra proteger.
- Componente que verifica:
VerifyCsrfTokenno Laravel,CsrfFilterno Spring. - Onde o token fica: sessão no Laravel; sessão (padrão) ou cookie no Spring.
- No formulário:
@csrfno Blade;th:actionno Thymeleaf, que injeta o campo sozinho. - Nome do campo:
_tokenno Laravel,_csrfno Spring. - Header aceito:
X-CSRF-TOKENno Laravel;X-CSRF-TOKENouX-XSRF-TOKENno Spring. - Erro quando falha:
419 Page Expiredno Laravel,403 Forbiddenno Spring. - Token muda a cada requisição: só no Spring, por causa da proteção contra BREACH.
- Isentar rotas: deixar fora do middleware
webno Laravel;ignoringRequestMatchers()no Spring.
#Resumindo
Sim, o Spring tem o equivalente ao @csrf, e vem ligado sem você pedir. Num app tradicional com Thymeleaf, a experiência é tão transparente quanto no Laravel: você usa th:action e esquece que o assunto existe.
O que muda é a camada extra. O Spring embaralha o token a cada resposta pra se defender do BREACH e só o carrega quando alguém precisa dele. As duas decisões são boas do ponto de vista de segurança e performance, mas explicam quase todo 403 misterioso que aparece quando se conecta uma SPA a um backend Spring. Sabendo disso, o conserto é uma configuração — no Spring Security 7, literalmente csrf.spa().
E se a sua vontade for resolver tudo com csrf.disable(), para e pergunta antes: essa aplicação autentica alguém por cookie de sessão? Se a resposta for sim, você não está resolvendo o problema, está só apagando o alarme.