Caso real · Spring Security

Spring Security retornando 403 em POST, PUT e DELETE com React: como resolver o CSRF em SPA

Edson Feitosa de Oliveira Junior
Edson Feitosa de Oliveira Junior Fundador · PruPru Digital Services · Natal/RN

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:

  1. 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.
  2. 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-TOKEN pode nem chegar a ser criado antes da primeira escrita.

Como confirmar no navegador

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

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 →

Sobre o autor

Edson Feitosa de Oliveira Junior é fundador da PruPru Digital Services, em Natal/RN. Trabalhou 15 anos como técnico em segurança eletrônica (2002–2017), migrou para o desenvolvimento de software e atuou como desenvolvedor no Mercado Livre (2022–2025). Hoje é desenvolvedor backend Java em uma distribuidora de Natal e cursa Engenharia de Software (UniFatecie) e Sistemas para Internet (IFRN). Os casos deste blog marcados como “caso real” vêm de sistemas que ele construiu e mantém em produção.

Falar no WhatsApp agora