Você atualizou pro Spring Boot 4, rodou os testes locais, deu deploy. Aí o primeiro POST em produção voltou 403. Sem stack trace, sem log de erro, sem nada útil no console. Você revisa o token JWT, mexe no CORS, confere o filtro de autenticação, e tudo parece certo. O GET funciona, mas todo método de escrita morre com 403.
A causa raiz não está no seu código. O Spring Security 7, que vem embarcado no Spring Boot 4, ligou a proteção CSRF nas suas APIs sem te avisar. Nesse artigo você vai entender por que isso acontece, como diagnosticar em dois minutos e como corrigir do jeito certo, sem sair desabilitando segurança no grito.
Você atualizou pro Spring Boot 4, rodou os testes locais, deu deploy. Aí o primeiro POST em produção voltou 403. Sem stack trace, sem log de erro, sem nada útil no console. Você revisa o token JWT, mexe no CORS, confere o filtro de autenticação, e tudo parece certo. O GET funciona, mas todo método de escrita morre com 403.
A causa raiz não está no seu código. O Spring Security 7, que vem embarcado no Spring Boot 4, ligou a proteção CSRF nas suas APIs sem te avisar. Nesse artigo você vai entender por que isso acontece, como diagnosticar em dois minutos e como corrigir do jeito certo, sem sair desabilitando segurança no grito.
Como Tech Leader, já vi esse 403 consumir um dia inteiro de um time inteiro numa janela de migração. Todo mundo olhando pro lugar errado. Bora destrinchar isso direito.
O que o Spring Security 7 mudou no CSRF
CSRF (Cross-Site Request Forgery) é um ataque em que um site malicioso faz o navegador da vítima disparar uma requisição autenticada para a sua aplicação, aproveitando o cookie de sessão que já está no browser. A proteção clássica do Spring contra isso é exigir um token anti-CSRF em todo método que altera estado: POST, PUT, PATCH e DELETE.
Até o Spring Security 6, muita gente vivia com uma verdade prática: em API REST stateless com JWT, o CSRF era frequentemente desligado de forma explícita, e quando não era, o comportamento default acabava deixando passar em vários cenários de configuração. Com o Spring Security 7 no Spring Boot 4, o default ficou mais rígido e mais consistente: a proteção CSRF se aplica de forma firme a todas as requisições de escrita, inclusive nas suas rotas /api. Se a requisição não traz o token esperado, a resposta é um 403 Forbidden seco, antes mesmo de chegar no seu controller.
O detalhe cruel: o 403 do CSRF não gera exceção no seu código. Ele acontece dentro do filter chain do Security, então não cai no seu @ExceptionHandler, não aparece um NullPointerException bonito no log, nada. É por isso que o time inteiro vai caçar no JWT primeiro. Veja como ficava uma configuração típica de Boot 3 que dependia do comportamento antigo:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
// Em Boot 3 muita app nem tocava no csrf e seguia a vida
.build();
}
}
Esse mesmo bean, recompilado contra o Spring Boot 4, passa a aplicar o CSRF nas rotas de escrita. O código não mudou. O default por baixo dele mudou. Essa é a pegadinha que detona a migração. O comportamento está documentado no guia oficial de CSRF do Spring Security e no Spring Boot 4.0 Migration Guide.
Por que o 403 cai justamente nos endpoints de escrita
Repare no sintoma e ele já entrega o diagnóstico: o GET funciona, o POST não. Isso é a assinatura do CSRF. A proteção, por design, ignora métodos considerados seguros (GET, HEAD, OPTIONS, TRACE) e barra os métodos que alteram estado. Se o seu GET /api/pedidos responde 200 e o POST /api/pedidos responde 403 com o mesmo token de autenticação, pare de olhar pro JWT. É CSRF.
Dá pra reproduzir o problema na unha com um curl. Autentique, pegue o seu Bearer token normalmente, e dispare um POST:
curl -i -X POST https://api.suaempresa.com/api/pedidos \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsIn..." \
-H "Content-Type: application/json" \
-d '{"produto":"teclado","quantidade":2}'
# Resposta no Spring Boot 4 sem ajuste:
# HTTP/1.1 403 Forbidden
# (corpo vazio, nenhuma exceção no log da aplicacao)
Token válido, usuário autenticado, e mesmo assim 403. Quando você confirma que o GET com o mesmo header passa, o veredito está fechado. Para deixar o log a seu favor durante a investigação, suba o nível de log do Security e o filtro vai te contar exatamente quem barrou a requisição:
# application.yaml
logging:
level:
org.springframework.security.web.csrf: DEBUG
Com isso ligado, você passa a ver no console uma mensagem do tipo "Invalid CSRF token found for /api/pedidos". É a confirmação que faltava. Agora vem a parte importante: corrigir do jeito certo.
O fix errado: csrf.disable() no projeto inteiro
A primeira resposta que aparece no Stack Overflow é também a mais perigosa: desligar o CSRF globalmente. Funciona, o 403 some, e por isso muita gente comemora e segue em frente. Só que se a sua aplicação serve qualquer coisa baseada em sessão e cookie (um painel admin, um formulário de login server-side, uma área logada com Thymeleaf), você acabou de abrir a porta pro ataque que o CSRF existe pra barrar.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
// NÃO faça isso "pra resolver rápido": desliga a protecao do site todo
.csrf(CsrfConfigurer::disable)
.build();
}
Esse é o tipo de atalho que passa no deploy e reprova na auditoria de segurança. Em entrevista de vaga sênior, responder "eu desabilito o CSRF" é justamente o que separa quem entende de quem decorou. O caminho certo é diferenciar o que é stateless do que é stateful.
O fix certo para API REST stateless com JWT
A regra de ouro: CSRF só importa quando o navegador anexa credenciais automaticamente, e isso acontece com cookie de sessão. Numa API REST de verdade stateless, onde o cliente manda o JWT no header Authorization, o navegador não anexa esse header sozinho num ataque cross-site. Logo, o CSRF não agrega proteção ali e pode ser desligado com segurança, desde que seja só para aquelas rotas e a sessão seja realmente stateless.
O jeito correto é declarar a cadeia da API como stateless e desligar o CSRF apenas no matcher dela, mantendo a proteção viva no resto:
@Bean
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
return http
// Esta cadeia cuida SÓ das rotas /api
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
// Sem sessao = sem cookie automatico = CSRF nao se aplica aqui
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
.build();
}
São três linhas que importam: o securityMatcher que isola a API, o STATELESS que deixa claro pro Spring que não existe sessão, e o ignoringRequestMatchers que tira o CSRF só de onde ele não faz sentido. O resto do site continua protegido. Esse é o padrão que os sêniores usam, não o disable() preguiçoso.
E se a sua app usa sessão e cookie? Mantenha o CSRF, do jeito moderno
Tem aplicação que mistura: um front em Angular ou React conversando com o backend via cookie de sessão, ou um painel administrativo renderizado no servidor. Nesses casos você não quer desligar o CSRF, quer fazê-lo funcionar. A abordagem recomendada hoje é o CookieCsrfTokenRepository, que entrega o token num cookie legível pelo JavaScript e espera ele de volta no header X-XSRF-TOKEN:
@Bean
public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.csrf(csrf -> csrf
// Token vai num cookie que o front consegue ler
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
// Handler que resolve o token de forma compativel com SPA
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
)
.build();
}
No lado do cliente, o trabalho é ler o cookie XSRF-TOKEN e devolvê-lo no header a cada chamada de escrita. Boa parte dos HTTP clients de SPA já faz isso automaticamente quando o cookie tem o nome certo. Quem fizer na mão, fica assim:
// Front-end: ler o cookie e mandar de volta no header
const csrf = document.cookie
.split('; ')
.find(c => c.startsWith('XSRF-TOKEN='))
?.split('=')[1];
await fetch('/api/pedidos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(csrf)
},
body: JSON.stringify({ produto: 'teclado', quantidade: 2 })
});
Pensa no CSRF token como a senha do Wi-Fi de uma festa. Quem está dentro de casa (o seu front, servido pela sua origem) recebe a senha e entra. Um site malicioso aberto em outra aba até consegue forçar o navegador a bater na porta, mas não tem a senha, então fica do lado de fora. Desligar o CSRF é tirar a senha e deixar a porta encostada.
O que muda na prática durante a migração
Na vida real, o roteiro de migração pro Spring Boot 4 com esse cuidado fica curto e previsível. Primeiro você separa mentalmente as duas naturezas da sua aplicação: o que é API stateless e o que é web com sessão. Depois aplica a configuração certa pra cada cadeia. E aí testa de propósito o cenário que quebra, antes que o cliente faça isso por você.
Um detalhe que pega muita gente desavisada no mesmo PR: o Spring Boot 4 também troca o Jackson 2 pelo Jackson 3, com mudança de pacote (de com.fasterxml.jackson para tools.jackson). Se além do 403 você vir erros de serialização, é outra frente da mesma migração. Vale tratar as duas juntas pra não fazer dois deploys de dor. Falamos mais sobre o ecossistema do Boot 4 nos nossos conteúdos de Spring Boot e migração de versão, e a parte de concorrência moderna que costuma aparecer junto está na nossa série sobre Virtual Threads no Java 25.
Para não voltar a ser pego de surpresa, blinde a regressão com um teste que falha de propósito sem o token e passa com ele. Esse teste vale ouro numa esteira de CI:
@WebMvcTest(PedidoController.class)
class CsrfRegressionTest {
@Autowired
MockMvc mockMvc;
@Test
void postSemTokenDeveRetornar403() throws Exception {
mockMvc.perform(post("/api/pedidos")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"produto\":\"teclado\"}"))
.andExpect(status().isForbidden());
}
@Test
void postComTokenDevePassar() throws Exception {
mockMvc.perform(post("/api/pedidos").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"produto\":\"teclado\"}"))
.andExpect(status().isOk());
}
}
O ganho real aqui não é só apagar o incêndio do 403. É sair da migração com a postura de segurança intencional, sabendo exatamente onde o CSRF está ligado e por quê. No mercado de hoje, esse nível de clareza é o que diferencia o dev que "fez funcionar" do dev que "fez certo".
Perguntas frequentes
Por que o GET funciona e só o POST dá 403 depois de migrar pro Spring Boot 4?
Porque a proteção CSRF, por design, ignora métodos seguros como GET, HEAD e OPTIONS, e exige token nos métodos de escrita (POST, PUT, PATCH, DELETE). Se o seu GET passa e o POST com o mesmo token de autenticação responde 403, o problema é CSRF, não autenticação.
Desabilitar o CSRF é sempre errado?
Não. Em uma API REST genuinamente stateless, onde o cliente envia o JWT no header Authorization e não há cookie de sessão, o CSRF não adiciona proteção e pode ser desligado com segurança naquele matcher. O erro é desligar globalmente quando a aplicação também tem áreas baseadas em sessão e cookie.
Como mando o token CSRF a partir de um front Angular ou React?
Use o CookieCsrfTokenRepository no backend para emitir o token em um cookie XSRF-TOKEN e configure o cliente para reenviar esse valor no header X-XSRF-TOKEN a cada requisição de escrita. Muitos HTTP clients de SPA fazem isso automaticamente quando o cookie tem o nome padrão.
Esse comportamento é exclusivo do Spring Boot 4?
A mudança vem do Spring Security 7, que é a versão embarcada no Spring Boot 4. Ao recompilar a mesma configuração contra o Boot 4, os defaults mais rígidos passam a valer mesmo sem alteração no seu código.
Resumo e próximo passo
- 403 só nos métodos de escrita após migrar é assinatura de CSRF, não de autenticação. Pare de mexer no JWT.
- API stateless com JWT: isole com securityMatcher, marque a sessão como STATELESS e use ignoringRequestMatchers só nas rotas da API.
- App com sessão e cookie: mantenha o CSRF vivo com CookieCsrfTokenRepository e o header X-XSRF-TOKEN, nunca o disable() global.
- Blinde com teste de regressão que valida 403 sem token e 200 com token, direto na CI.
Você já levou esse 403 fantasma numa migração de Spring Boot? Resolveu no disable() ou separou as cadeias do jeito certo? Conta nos comentários, quero saber como foi na sua stack.
Na próxima publicação a gente abre a outra caixa de surpresas do Boot 4: a migração do Jackson 2 para o Jackson 3, com os serializers custom que quebram em silêncio. Fica de olho aqui no Meu Universo Nerd.