Protegendo REST API com Spring Security e JWT

1. Visão geral

REST API geralmente não usa session. Cada request deve carregar consigo as informações de autenticação, e JWT (JSON Web Token) é a forma mais comum de fazer isso.

Este guia mostra como proteger uma API de gerenciamento de pedidos com Spring Security 6 e Spring Boot 3. Vamos gerar JWT quando o usuário fazer login, autenticar o token em toda request e autorizar por role. A última parte traz testes automatizados para os comportamentos principais.

Não vamos escrever um filter JWT manual. Em vez disso, usaremos OAuth2 Resource Server que já vem no Spring Security. Essa abordagem tem menos código e é mais segura.

2. Dependências Maven

Você precisa das seguintes dependências no pom.xml:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

O starter oauth2-resource-server traz a biblioteca Nimbus JOSE + JWT. Essa biblioteca cuida da assinatura e verificação de assinatura do token. Você não precisa adicionar jjwt ou nenhuma outra biblioteca JWT.

3. Configuração da chave secreta

Para simplificar, vamos assinar tokens usando o algoritmo simétrico HS256. A mesma chave secreta é usada para assinar e para verificar a assinatura.

jwt.secret=${JWT_SECRET:substitua-isso-por-uma-chave-secreta-longa-com-pelo-menos-32-bytes}
jwt.expiration-minutes=15

A expressão ${JWT_SECRET:...} lê a chave da variável de ambiente JWT_SECRET. Se essa variável não existir, Spring usa o valor padrão após os dois pontos. O valor padrão é apenas para ambiente de desenvolvimento.

Atenção: HS256 exige uma chave com comprimento mínimo de 256 bits, ou seja, 32 bytes. Se a chave for mais curta, Nimbus lançará KeyLengthException na primeira vez que tentar assinar um token.

4. Configuração do Spring Security

4.1. JwtEncoder, JwtDecoder e usuários

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Value("${jwt.secret}")
    private String secret;

    @Bean
    SecretKey jwtSecretKey() {
        return new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
    }

    @Bean
    JwtEncoder jwtEncoder(SecretKey key) {
        return new NimbusJwtEncoder(new ImmutableSecret<>(key));
    }

    @Bean
    JwtDecoder jwtDecoder(SecretKey key) {
        return NimbusJwtDecoder.withSecretKey(key)
                .macAlgorithm(MacAlgorithm.HS256)
                .build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    UserDetailsService userDetailsService(PasswordEncoder encoder) {
        var customer = User.withUsername("alice")
                .password(encoder.encode("alice123")).roles("CUSTOMER").build();
        var admin = User.withUsername("bob")
                .password(encoder.encode("bob123")).roles("CUSTOMER", "ADMIN").build();
        return new InMemoryUserDetailsManager(customer, admin);
    }

    @Bean
    AuthenticationManager authenticationManager(UserDetailsService uds, PasswordEncoder encoder) {
        var provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(uds);
        provider.setPasswordEncoder(encoder);
        return new ProviderManager(provider);
    }
}

O papel de cada bean:

  • SecretKey encapsula a string secreta em uma chave HMAC-SHA256.
  • JwtEncoder usa essa chave para assinar o token quando o usuário faz login.
  • JwtDecoder verifica a assinatura e a validade (exp) do token em cada request. O Resource Server usa esse bean automaticamente.
  • InMemoryUserDetailsManager cria dois usuários de exemplo. Em produção, você substitui com uma implementação que lê do banco de dados.
  • AuthenticationManager verifica username e senha no endpoint de login.

4.2. SecurityFilterChain

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
    return http.build();
}

@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
    var authorities = new JwtGrantedAuthoritiesConverter();
    authorities.setAuthoritiesClaimName("roles");
    authorities.setAuthorityPrefix("");

    var converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(authorities);
    return converter;
}

Esse bean fica na mesma classe SecurityConfig. Cada parte faz o seguinte:

  • csrf.disable(): A API não usa cookies para autenticar, então não sofre com ataques CSRF.
  • STATELESS: Spring não cria HttpSession. Cada request é autenticada novamente a partir do token.
  • authorizeHttpRequests: o endpoint de login é aberto ao público. /api/admin/** é apenas para role ADMIN. Todos os outros endpoints exigem autenticação.
  • oauth2ResourceServer(...jwt...): ativa o filter que lê o header Authorization: Bearer <token> e decodifica o token com JwtDecoder.

Por padrão, Spring lê as autoridades do claim scope e adiciona o prefixo SCOPE_. Queremos usar hasRole("ADMIN"), então JwtAuthenticationConverter é reconfigurado para ler o claim roles. O prefixo é removido porque os valores no token já estão no formato ROLE_ADMIN.

5. Gerando token no login

5.1. TokenService

@Service
public class TokenService {

    private final JwtEncoder encoder;
    private final long expirationMinutes;

    public TokenService(JwtEncoder encoder,
                        @Value("${jwt.expiration-minutes}") long expirationMinutes) {
        this.encoder = encoder;
        this.expirationMinutes = expirationMinutes;
    }

    public String generateToken(Authentication authentication) {
        Instant now = Instant.now();
        List<String> roles = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .toList();

        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer("nonla-shop")
                .issuedAt(now)
                .expiresAt(now.plus(expirationMinutes, ChronoUnit.MINUTES))
                .subject(authentication.getName())
                .claim("roles", roles)
                .build();

        JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
        return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
    }

    public long getExpirationSeconds() {
        return expirationMinutes * 60;
    }
}

O token contém os seguintes claims: emissor (iss), momento da emissão (iat), momento da expiração (exp), username (sub) e lista de roles.

A linha JwsHeader.with(MacAlgorithm.HS256) é obrigatória. Se a omitir, NimbusJwtEncoder escolhe RS256 por padrão. Nesse caso, ele não encontrará uma chave apropriada e lançará o erro Failed to select a JWK signing key.

5.2. AuthController

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final TokenService tokenService;

    public AuthController(AuthenticationManager authenticationManager, TokenService tokenService) {
        this.authenticationManager = authenticationManager;
        this.tokenService = tokenService;
    }

    public record LoginRequest(String username, String password) {}
    public record TokenResponse(String accessToken, String tokenType, long expiresIn) {}

    @PostMapping("/login")
    public TokenResponse login(@RequestBody LoginRequest request) {
        Authentication auth = authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(
                        request.username(), request.password()));
        String token = tokenService.generateToken(auth);
        return new TokenResponse(token, "Bearer", tokenService.getExpirationSeconds());
    }
}

authenticate() verifica username e senha via DaoAuthenticationProvider. Se estiverem incorretos, o método lança BadCredentialsException. Spring Security traduz esse erro para HTTP 401. Se corretos, o controller gera o token e retorna para o cliente.

Teste com curl:

curl -s -X POST localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"alice123"}'
{"accessToken":"eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJub25sYS1zaG9wIiwic3ViIjoiYWxpY2UiLC...","tokenType":"Bearer","expiresIn":900}

6. Endpoints protegidos

@RestController
@RequestMapping("/api")
public class OrderController {

    public record Order(long id, String customer, BigDecimal total) {}

    private final List<Order> orders = List.of(
            new Order(1, "alice", new BigDecimal("250000")),
            new Order(2, "bob", new BigDecimal("990000")));

    @GetMapping("/orders")
    public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
        return orders.stream()
                .filter(o -> o.customer().equals(jwt.getSubject()))
                .toList();
    }

    @GetMapping("/admin/orders")
    public List<Order> allOrders() {
        return orders;
    }
}

@AuthenticationPrincipal Jwt jwt injeta o token já autenticado no controller. Você obtém o username com jwt.getSubject(). Para ler outros claims, use jwt.getClaim("roles").

Teste com três casos diferentes:

# Sem token
curl -i localhost:8080/api/orders

# alice com token válido
curl -s localhost:8080/api/orders -H "Authorization: Bearer $TOKEN"

# alice chama endpoint admin
curl -i localhost:8080/api/admin/orders -H "Authorization: Bearer $TOKEN"
HTTP/1.1 401
WWW-Authenticate: Bearer

[{"id":1,"customer":"alice","total":250000}]

HTTP/1.1 403
WWW-Authenticate: Bearer error="insufficient_scope", ...

O resultado mostra dois códigos de erro diferentes. 401 significa não autenticado: token ausente, assinatura inválida ou token expirado. 403 significa autenticado, mas sem permissão suficiente.

7. Escrevendo testes

spring-security-test fornece o post-processor jwt(). Ele simula um JWT já autenticado sem precisar assinar um token real. Combinamos isso com um teste end-to-end que tem um login real.

@SpringBootTest
@AutoConfigureMockMvc
class OrderSecurityTest {

    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    @Test
    void whenNoToken_thenUnauthorized() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/orders")).andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(401);
    }

    @Test
    void whenLoginAndCallWithToken_thenReturnOwnOrders() throws Exception {
        String body = mockMvc.perform(post("/api/auth/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"username":"alice","password":"alice123"}
                                """))
                .andReturn().getResponse().getContentAsString();
        String token = objectMapper.readTree(body).get("accessToken").asText();

        MvcResult result = mockMvc.perform(get("/api/orders")
                        .header("Authorization", "Bearer " + token))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(200);
        assertThat(result.getResponse().getContentAsString())
                .contains("\"customer\":\"alice\"")
                .doesNotContain("\"customer\":\"bob\"");
    }

    @Test
    void whenWrongPassword_thenUnauthorized() throws Exception {
        MvcResult result = mockMvc.perform(post("/api/auth/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"username\":\"alice\",\"password\":\"sai\"}"))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(401);
    }

    @Test
    void whenCustomerCallsAdminEndpoint_thenForbidden() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/admin/orders")
                        .with(jwt().jwt(j -> j.subject("alice"))
                                .authorities(new SimpleGrantedAuthority("ROLE_CUSTOMER"))))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(403);
    }
}

Os imports estáticos necessários são MockMvcRequestBuilders.*, SecurityMockMvcRequestPostProcessors.jwt e Assertions.assertThat do AssertJ.

O segundo teste valida o fluxo completo. Ele chama o endpoint de login, extrai o token e depois usa o token para chamar a API. Esse teste detecta erros de configuração do encoder e decoder, como esquecer o JwsHeader ou uma chave muito curta.

O último teste usa jwt() para verificar apenas a parte de autorização. Note que .authorities(...) sobrescreve o converter, então você precisa passar o nome correto da authority ROLE_CUSTOMER.

8. Erros comuns e quando não usar

Erros comuns:

  • Chave muito curta: uma chave com menos de 32 bytes causa KeyLengthException com HS256.
  • Esquecer header HS256: JwtEncoder relata Failed to select a JWK signing key.
  • Prefixo de authority incorreto: com o converter padrão, as autoridades têm formato SCOPE_.... Nesse caso, hasRole("ADMIN") sempre retorna 403. Verifique o claim e o prefixo para que correspondam.
  • Hardcode do secret no repositório: sempre leia o secret de uma variável de ambiente ou secret manager.
  • Colocar dados sensíveis no payload: o payload de JWT é apenas codificado em Base64URL, não criptografado. Qualquer um com o token pode ler o conteúdo. Não coloque senhas, números de cartão ou informações pessoais no token.

Atenção: JWT não pode ser revogado antes de expirar, a menos que você implemente um mecanismo adicional como um blacklist. Mantenha o access token de curta duração (5–15 minutos). Se precisar de sessões longas, use um refresh token armazenado no servidor para que possa ser revogado.

Quando não usar essa abordagem:

  • Aplicações web renderizadas no servidor, como Thymeleaf: sessão com cookies é mais simples e mais seguro.
  • Precisa fazer logout ou bloquear conta com efeito imediato: sessão ou token opaco com introspection são mais apropriados.
  • Sistema com vários serviços: compartilhar uma chave HMAC entre todos os serviços é muito arriscado. Nesse caso, você deve usar um authorization server separado, como Spring Authorization Server ou Keycloak. O authorization server assina tokens com RSA ou EC, e os serviços apenas precisam configurar spring.security.oauth2.resourceserver.jwt.issuer-uri para buscar a chave pública via JWKS.

9. Conclusão

Este artigo construiu uma REST API stateless protegida com JWT, consistindo dos seguintes passos:

  • Usar NimbusJwtEncoder e NimbusJwtDecoder com chave HS256.
  • Gerar token no endpoint /api/auth/login através de AuthenticationManager.
  • Deixar o OAuth2 Resource Server autenticar automaticamente o header Bearer.
  • Mapear o claim roles para authority para usar hasRole.
  • Testar os casos 401, 403 e o fluxo bem-sucedido com MockMvc e AssertJ.

A partir dessa base, você pode substituir InMemoryUserDetailsManager por usuários armazenados em banco de dados, adicionar refresh tokens ou mudar para chaves assimétricas quando o sistema crescer para vários serviços.

spring-bootspring-securityjwtrest-apijava