Escritos
Autenticação com sessão e JWT usando Spring Security
Neste artigo
Quando uma requisição chega a uma API, precisamos identificar quem está chamando e quais operações essa identidade pode executar. Aceitar um usuário autenticado não significa permitir que ele acesse qualquer recurso.
Sessão e JWT são duas formas de transportar informações usadas nessa identificação. A escolha altera onde o estado fica, como o cliente envia a credencial e o que acontece quando o usuário sai da aplicação.
O exemplo deste texto usa Java 21, Spring Boot 3.5.16 e Spring Security. O projeto completo, incluindo os testes, está disponível para download.
Autenticação e autorização
Autenticação verifica a identidade. No login com senha, precisamos verificar se a credencial corresponde ao usuário. Em uma requisição com token, precisamos verificar se aquele token é aceitável para a API.
Autorização decide o que a identidade pode fazer. Um usuário pode estar autenticado e receber 403 ao tentar uma operação que exige uma permissão que ele não possui.
O código a seguir exige autenticação, mas não verifica propriedade de dados:
.requestMatchers("/pedidos/**").authenticated()
Se /pedidos/123 retorna o pedido de outra pessoa sem uma verificação adicional, a configuração acima não impede esse acesso. O serviço precisa aplicar a regra do recurso: proprietário, organização, papel ou outra condição definida pelo domínio.
Sessão
No fluxo com formulário do exemplo, o Spring Security verifica usuário e senha. Depois do login, o contexto de segurança fica associado à sessão HTTP. O navegador envia o identificador da sessão em um cookie nas próximas requisições.
O servidor mantém o estado da sessão. O cliente guarda um identificador, que também precisa ser protegido. Quem conseguir usar uma sessão válida pode agir com aquela identidade.
@Bean @Order(2)
SecurityFilterChain navegador(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(a -> a
.requestMatchers("/login", "/error").permitAll()
.requestMatchers("/web/**").authenticated()
.anyRequest().denyAll())
.formLogin(Customizer.withDefaults())
.logout(Customizer.withDefaults());
return http.build();
}
/web/** usa o login por formulário. Uma chamada sem autenticação, nesse fluxo, pode receber um redirecionamento para a página de login, em vez do 401 esperado por um cliente de API.
O exemplo mantém um usuário em memória para executar esse fluxo. A senha é definida por variável de ambiente e transformada por um PasswordEncoder. Não há cadastro ou banco de usuários nesse projeto.
var encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
return new InMemoryUserDetailsManager(
User.withUsername("neto")
.password(encoder.encode(senha))
.roles("USER")
.build()
);
Um sistema com persistência deve armazenar a representação produzida pelo encoder, não a senha original. Hash de senha e criptografia reversível têm finalidades diferentes.
Cookie e CSRF
O navegador pode anexar cookies automaticamente. Por isso, uma requisição iniciada por outra página pode carregar a sessão do usuário. Uma operação que altera dados precisa tratar CSRF.
A proteção CSRF permanece habilitada na cadeia /web/**. Um POST autenticado sem token CSRF é rejeitado. O formulário padrão de login já inclui o token; o frontend precisa enviá-lo nas outras operações protegidas.
HttpOnly limita o acesso ao cookie por JavaScript. Secure restringe o envio a HTTPS. SameSite controla o envio do cookie em contextos entre sites. São controles diferentes e precisam ser compatíveis com o fluxo da aplicação.
O exemplo usa HTTP apenas em localhost. Em uma implantação com HTTPS, SESSION_COOKIE_SECURE deve ser true. O teste com MockMvc não verifica o comportamento real do navegador nem o cabeçalho final de um servidor em produção.
JWT
JWT é um formato de token. No caso de um JWT assinado, validar a assinatura permite verificar a integridade e a relação com a chave esperada. O conteúdo de um token apenas assinado continua legível; a assinatura não cifra os dados.
Também precisamos avaliar as claims. Um token pode ter assinatura válida e ainda estar expirado, ter sido emitido por outro provedor ou ser destinado a outra API.
| Claim | O que informa |
|---|---|
sub | O sujeito identificado pelo emissor |
iss | Quem emitiu o token |
aud | Para quem o token foi emitido |
exp | Quando termina a validade |
nbf | A partir de quando pode ser aceito |
As regras de aceitação são parte do contrato entre API e emissor. No exemplo, usamos um access token RS256 com emissor e audience definidos. O código não emite tokens e não implementa um servidor de autorização.
A cadeia da API
As rotas /api/** recebem um bearer token no cabeçalho Authorization. Essa cadeia tem prioridade sobre a configuração do navegador:
@Bean @Order(1)
SecurityFilterChain api(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**")
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.requestCache(c -> c.disable())
.csrf(c -> c.disable())
.authorizeHttpRequests(a -> a
.requestMatchers("/api/conta").hasAuthority("SCOPE_conta:ler")
.anyRequest().denyAll())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
return http.build();
}
O Spring Security escolhe a primeira cadeia cujo matcher atende à requisição. Uma cadeia sem securityMatcher específico atende ao restante. Se alguma rota ficar fora de todas as cadeias, ela fica fora da proteção configurada por elas. Neste exemplo, a segunda cadeia cobre o restante e nega as rotas que não foram declaradas.
STATELESS impede o uso da sessão para obter ou persistir o contexto de segurança dessa cadeia. Uma sessão válida em /web/conta não autentica /api/conta.
Desabilitamos CSRF apenas na cadeia que aceita bearer token enviado explicitamente no cabeçalho. Se o token for colocado em um cookie usado automaticamente pelo navegador, essa justificativa muda. A existência de um JWT não elimina CSRF. Também não existe uma regra de CORS capaz de substituir autenticação ou autorização.
Validar o token
O projeto configura o endereço das chaves públicas do emissor e acrescenta a validação da audience:
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwks).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer),
new JwtClaimValidator<java.time.Instant>("exp", exp -> exp != null),
new JwtClaimValidator<String>("sub", sub -> sub != null && !sub.isBlank()),
new JwtClaimValidator<java.util.List<String>>(
"aud", aud -> aud != null && aud.contains(audience)
)
));
issuer, jwks e audience são configurações do servidor. Não devem ser escolhidos a partir de valores que um token recebido fornece. No projeto, vêm de variáveis de ambiente.
O decoder verifica a assinatura com a chave esperada e aplica os validadores. Neste contrato, exp precisa existir e sub não pode ser vazio. Validar a data de uma claim quando ela está presente não equivale a exigir sua presença.
As verificações temporais têm uma tolerância para diferença de relógio. Por isso, no teste de expiração, o token venceu há cinco minutos, evitando uma falha que dependa de poucos segundos.
A configuração de Resource Server converte scopes em authorities com o prefixo SCOPE_. Assim, a claim scope com valor conta:ler atende à exigência de SCOPE_conta:ler na rota.
Decodificar Base64 e ler sub não executa essas verificações. Também não precisamos criar um filtro próprio que faz parsing do token para repetir o trabalho do Resource Server.
Logout e validade
No fluxo com sessão, o logout do exemplo invalida a sessão. No fluxo com JWT, apagar o token do cliente não torna inválidas as cópias que ainda existem. Elas podem continuar sendo aceitas até a expiração, conforme a política da API.
Se o produto exige revogação imediata, esse requisito precisa entrar no desenho: estado de revogação, consulta ao provedor, versão de credenciais ou outro mecanismo verificável. Isso também altera o argumento de que “JWT não precisa de estado”.
Tokens de acesso e refresh tokens têm funções diferentes. Esta API recebe access tokens. A obtenção e a renovação ficam sob responsabilidade do cliente e do provedor escolhido.
Testes que exercitam o filtro
O projeto usa MockMvc e tokens realmente assinados com chaves RSA geradas durante o teste. Um decoder local substitui o acesso ao provedor externo, mantendo a validação criptográfica e as regras de claims.
O teste de audience, por exemplo, mantém a assinatura válida e muda o destinatário:
@Test void audienceDiferenteRetorna401() throws Exception {
String jwt = token(CHAVE, "https://id.example.invalid", "outra-api",
Instant.now().plusSeconds(300), "conta:ler");
mvc.perform(get("/api/conta")
.header("Authorization", "Bearer " + jwt))
.andExpect(status().isUnauthorized());
}
Para testar CSRF, primeiro autenticamos pelo formulário e reutilizamos a sessão criada:
mvc.perform(post("/web/preferencias").session(sessao))
.andExpect(status().isForbidden());
mvc.perform(post("/web/preferencias").session(sessao).with(csrf()))
.andExpect(status().isNoContent());
O projeto também verifica token ausente, assinatura com outra chave, token expirado, ausência de expiração, sujeito vazio, emissor incorreto, scope insuficiente, login inválido, troca do ID da sessão após login e invalidação no logout.
jwt() do módulo de testes é útil para cenários de autorização, mas fornece uma autenticação preparada para a requisição. Ele não prova que uma string de token passou pelo decoder e teve sua assinatura validada. Os testes de token deste exemplo enviam o cabeçalho real justamente para percorrer esse caminho.
Depois de extrair o projeto, execute:
mvn test
São doze testes. Eles verificam os limites da configuração do exemplo, não a integração com um provedor real, persistência de usuários ou autorização de recursos de um domínio. Essas verificações precisam existir quando esses componentes forem adicionados.