Spring Security retornando 403 em POST, PUT e DELETE com React: como resolver o CSRF em SPA
Num sistema web que desenvolvi com Spring Boot 3 + React, as telas de consulta funcionavam, mas qualquer operação de escrita (POST, PUT, PATCH, DELETE) voltava 403 Forbidden. Até o botão de sair, que faz POST /logout, falhava. O usuário estava autenticado, o cookie de sessão estava lá e o token CSRF também estava sendo enviado no cabeçalho.
O problema não estava no React. Estava na forma como o Spring Security 6 passou a tratar o token CSRF, algo que muitos tutoriais antigos não cobrem.
A configuração que costuma aparecer nos tutoriais
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()));
A ideia é clássica: o backend grava o token num cookie XSRF-TOKEN legível por JavaScript, e o frontend devolve o valor no cabeçalho X-XSRF-TOKEN. O Axios faz isso sozinho. Até o Spring Security 5 isso bastava.
O que mudou no Spring Security 6
Duas mudanças de padrão quebram essa configuração:
- Proteção contra BREACH: o tratador padrão passou a ser o
XorCsrfTokenRequestAttributeHandler. Ele espera receber o token mascarado (embaralhado a cada requisição), mas o cookie guarda o token cru. O React devolve o valor do cookie, o Spring tenta desmascarar um valor que não foi mascarado, a comparação falha e a resposta é 403. - Carregamento adiado do token: o token só é gerado quando alguma parte do código o lê. Numa SPA, o servidor nunca renderiza um formulário, então o cookie
XSRF-TOKENpode nem chegar a ser criado antes da primeira escrita.
Como confirmar no navegador
- Abra o DevTools → aba Application → Cookies e veja se existe o cookie
XSRF-TOKEN. - Na aba Network, abra a requisição que falhou e confira se o cabeçalho
X-XSRF-TOKENfoi enviado com o mesmo valor do cookie. - Se o cookie e o cabeçalho batem e mesmo assim dá 403, é quase certamente o tratador XOR descrito acima.
- Ligue
logging.level.org.springframework.security=DEBUG: a linha Invalid CSRF token found confirma a causa.
A correção: um tratador próprio para SPA
A documentação oficial do Spring Security traz um exemplo para aplicações de página única. Foi essa a base que usei. A ideia é aceitar o token cru quando ele vem no cabeçalho e manter a proteção BREACH para o restante:
final class SpaCsrfTokenRequestHandler extends CsrfTokenRequestAttributeHandler {
private final CsrfTokenRequestHandler delegate = new XorCsrfTokenRequestAttributeHandler();
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
Supplier<CsrfToken> csrfToken) {
// mantém a proteção BREACH no token exposto para a página
this.delegate.handle(request, response, csrfToken);
}
@Override
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
// token no cabeçalho (enviado pelo React a partir do cookie): valor cru
if (StringUtils.hasText(request.getHeader(csrfToken.getHeaderName()))) {
return super.resolveCsrfTokenValue(request, csrfToken);
}
// token em parâmetro de formulário: valor mascarado
return this.delegate.resolveCsrfTokenValue(request, csrfToken);
}
}
E um filtro que força a geração do token em toda resposta, para o cookie existir antes da primeira escrita:
final class CsrfCookieFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
CsrfToken csrfToken = (CsrfToken) request.getAttribute("_csrf");
if (csrfToken != null) {
csrfToken.getToken(); // carrega o token adiado e grava o cookie
}
chain.doFilter(request, response);
}
}
Na configuração de segurança:
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class);
Depois disso, todas as escritas e o logout voltaram a funcionar, e o CSRF continuou ativo. Versões mais novas do Spring Security trazem atalhos para esse cenário. Vale conferir a documentação da versão que você usa antes de copiar a classe.
Do lado do React
- Com Axios, o cabeçalho é enviado automaticamente a partir do cookie
XSRF-TOKEN, desde que frontend e API estejam no mesmo domínio. Se estiverem em domínios diferentes, usewithCredentials: trueewithXSRFToken: true(Axios 1.6 ou mais novo). - Com fetch, leia o cookie e envie o cabeçalho
X-XSRF-TOKENmanualmente, comcredentials: 'include'. - Faça uma requisição
GETqualquer logo após o login, para o servidor gravar o cookie antes da primeira escrita.
O que não fazer
A solução mais repetida em fóruns é http.csrf(csrf -> csrf.disable()). Ela só é segura quando a autenticação não usa cookie (por exemplo, token JWT enviado no cabeçalho Authorization). Se a sessão ou o JWT ficam em cookie, como no meu caso (cookie HttpOnly), desligar o CSRF deixa a aplicação aberta para ataques de requisição forjada.
Outro erro comum é liberar CORS com * para “resolver” o 403. No mesmo projeto, removi o CORS curinga e adicionei uma verificação que impede a aplicação de subir se alguém configurar * de novo. CORS com lista explícita de origens e CSRF ativo funcionam juntos sem conflito.
Perguntas frequentes
Por que o Spring Security retorna 403 em POST mas GET funciona?
Porque GET, HEAD e OPTIONS não exigem token CSRF. Toda requisição que altera dados exige, e se o token enviado não bater com o esperado, o Spring responde 403.
Posso simplesmente desativar o CSRF?
Só se a autenticação não usar cookie, como um JWT enviado no cabeçalho Authorization. Se a sessão ou o token ficam em cookie, desativar o CSRF abre a aplicação para requisições forjadas.
O cookie XSRF-TOKEN não aparece no navegador. O que fazer?
No Spring Security 6 o token é carregado sob demanda. Adicione um filtro que leia o token em toda requisição (como o CsrfCookieFilter deste post) e faça um GET após o login para o cookie ser gravado.
Precisa de ajuda com um sistema assim?
A PruPru desenvolve e corrige sistemas web em Java/Spring Boot e React para empresas de Natal/RN e do restante do Brasil. Conte o problema pelo WhatsApp e receba um diagnóstico sem compromisso.
Ver serviços →