Autenticação com JWT: RSA Keys vs HMAC256 no Spring Security

Imagem de capa: Autenticação com JWT: RSA Keys vs HMAC256 no Spring Security

1. As duas abordagens

RSA Keys (Assimétrica) — abordagem recomendada para produção

Usa um par de chaves pública/privada:

  • A chave privada assina (encoda) o JWT — fica apenas no servidor
  • A chave pública verifica (decoda) o JWT — pode ser compartilhada

Para criar as chaves usamos os sequintes comandos no terminal:

openssl genrsa > src/main/resources/app.key
cd src/main/resources
openssl rsa -in app.key -pubout -out app.pub

No Spring Boot, as chaves são configuradas assim em SecurityConfig.java:

@Value("${jwt.public.key}")
private RSAPublicKey publicKey;

@Value("${jwt.private.key}")
private RSAPrivateKey privateKey;

@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withPublicKey(publicKey).build();
}

@Bean
public JwtEncoder jwtEncoder() {
    JWK jwk = new RSAKey.Builder(this.publicKey).privateKey(privateKey).build();
    var jwks = new ImmutableJWKSet<>(new JWKSet(jwk));
    return new NimbusJwtEncoder(jwks);
}

E o application.yaml aponta para os arquivos de chave:

jwt:
  public:
    key: classpath:/app.pub
  private:
    key: classpath:/app.key

HMAC256 (Simétrica) — abordagem mais simples, mas menos segura

Usa um único segredo compartilhado para assinar e verificar:

String secret = "my-super-secret-key-12345";
JwtBuilder jwtBuilder = Jwts.builder()
    .setSubject(userId)
    .signWith(SignatureAlgorithm.HS256, secret);

2. Comparação direta

RSA (Assimétrica)

Chave → par público/privado
  • Apenas o servidor (detentor da chave privada) pode assinar tokens
  • Qualquer serviço com a chave pública pode verificar — sem expor o segredo
  • Tokens são maiores e operações criptográficas mais lentas
  • Rotação de chaves segura — troca a privada sem afetar quem só verifica

HMAC256 (Simétrica)

Chave → segredo único compartilhado
  • O mesmo segredo é usado para assinar e verificar
  • Qualquer serviço que precise verificar precisa ter acesso ao segredo completo
  • Tokens menores e operações mais rápidas
  • Rotação de chave invalida todos os tokens existentes imediatamente

Quando usar cada uma

Use RSA quando:

  • A aplicação é ou pode virar um conjunto de microservices
  • Terceiros precisam verificar seus tokens (ex: parceiros, SDKs)
  • Segurança é prioridade sobre performance

Use HMAC256 quando:

  • É um projeto simples e monolítico
  • Ambiente de desenvolvimento local
  • Performance é crítica e o ambiente é controlado

3. Validação de tokens

No Spring Security com OAuth2 Resource Server, a validação é automática. Essa linha no SecurityConfig é responsável por tudo:

.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))

O que é validado automaticamente:

  • Assinatura (via chave pública RSA)
  • Expiração (exp claim)
  • issuedAt (iat claim)
  • Estrutura básica do JWT

Se qualquer validação falhar → Spring retorna 401 Unauthorized automaticamente.

3.1 Acessando claims do token no Controller/Service

import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;

@GetMapping("/profile")
public ResponseEntity<String> getProfile() {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    JwtAuthenticationToken jwt = (JwtAuthenticationToken) auth;

    String userId = jwt.getToken().getSubject();   // subject do token
    String issuer = jwt.getToken().getIssuer();    // "app-name"

    return ResponseEntity.ok("User: " + userId);
}

3.2 Validação customizada (opcional)

Útil para verificar se o usuário ainda existe no banco após o token ter sido emitido:

@Bean
public JwtDecoder jwtDecoder() {
    var jwtDecoder = NimbusJwtDecoder.withPublicKey(publicKey).build();

    var defaultValidator = JwtValidators.createDefaultWithIssuer("ai-powered-task-app");

    jwtDecoder.setJwtValidator(token -> {
        defaultValidator.validate(token);

        // Validação de negócio: checar se usuário ainda existe
        String userId = token.getSubject();
        if (!userRepository.existsById(Long.parseLong(userId))) {
            throw new JwtException("User not found or deactivated");
        }

        return OAuth2TokenValidatorResult.success();
    });

    return jwtDecoder;
}
A validação customizada acima é um exemplo ilustrativo — na prática, chamar o banco a cada request pode ter impacto de performance. Avalie se é necessário para o seu caso.

4. Refresh Token

Por que usar refresh tokens?

Access tokens de curta duração (ex: 30 minutos) melhoram a segurança, mas exigem que o cliente solicite um novo token periodicamente sem forçar novo login. Para isso, usamos refresh tokens.

4.1 Adicionando ao modelo de usuário

@Entity
@Table(name = "users")
public class User {
    // campos existentes...

    private String refreshToken;
    private Instant refreshTokenExpiryDate;
}
Lembre de criar a migration Flyway correspondente para adicionar essas colunas.

4.2 Atualizando o LoginResponse

public record LoginResponse(
        String accessToken,
        String refreshToken,
        Long expiresIn,
        String tokenType) {
}

4.3 Emitindo access token + refresh token no login

public LoginResponse authenticateUser(LoginRequest request) {
    User user = userRepository.findByEmail(request.email());

    if (user == null || !isLoginPasswordCorrect(request, user.getPassword())) {
        throw new InvalidCredentialsException("Invalid email or password");
    }

    var now = Instant.now();
    var accessTokenExpiresIn = 1800L;    // 30 minutos
    var refreshTokenExpiresIn = 604800L; // 7 dias

    // Access Token
    var accessTokenClaims = JwtClaimsSet.builder()
            .issuer("ai-powered-task-app")
            .subject(user.getId().toString())
            .issuedAt(now)
            .expiresAt(now.plusSeconds(accessTokenExpiresIn))
            .claim("type", "access")
            .build();

    var accessToken = jwtEncoder.encode(JwtEncoderParameters.from(accessTokenClaims)).getTokenValue();

    // Refresh Token
    var refreshTokenClaims = JwtClaimsSet.builder()
            .issuer("ai-powered-task-app")
            .subject(user.getId().toString())
            .issuedAt(now)
            .expiresAt(now.plusSeconds(refreshTokenExpiresIn))
            .claim("type", "refresh")
            .build();

    var refreshToken = jwtEncoder.encode(JwtEncoderParameters.from(refreshTokenClaims)).getTokenValue();

    // Salvar refresh token no banco
    user.setRefreshToken(refreshToken);
    user.setRefreshTokenExpiryDate(now.plusSeconds(refreshTokenExpiresIn));
    userRepository.save(user);

    return new LoginResponse(accessToken, refreshToken, accessTokenExpiresIn, "Bearer");
}

4.4 Endpoint de refresh com rotação de tokens

Rotação de refresh token é uma prática de segurança importante: a cada uso, o refresh token antigo é invalidado e um novo é gerado. Assim, um token roubado só funciona uma vez.

public LoginResponse refreshAccessToken(String oldRefreshToken) {
    try {
        // 1. Validar assinatura e expiração via JwtDecoder
        var jwt = jwtDecoder.decode(oldRefreshToken);

        String userId = jwt.getSubject();
        User user = userRepository.findById(Long.parseLong(userId))
                .orElseThrow(() -> new InvalidCredentialsException("User not found"));

        // 2. Verificar se o token bate com o armazenado no banco
        if (!oldRefreshToken.equals(user.getRefreshToken())) {
            throw new InvalidCredentialsException("Refresh token mismatch — possible token reuse attack");
        }

        // 3. Verificar expiração (dupla checagem além do decode)
        if (Instant.now().isAfter(user.getRefreshTokenExpiryDate())) {
            throw new InvalidCredentialsException("Refresh token expired");
        }

        var now = Instant.now();
        var accessTokenExpiresIn = 1800L;
        var refreshTokenExpiresIn = 604800L;

        // 4. Gerar novo access token
        var accessTokenClaims = JwtClaimsSet.builder()
                .issuer("ai-powered-task-app")
                .subject(userId)
                .issuedAt(now)
                .expiresAt(now.plusSeconds(accessTokenExpiresIn))
                .claim("type", "access")
                .build();

        var newAccessToken = jwtEncoder.encode(JwtEncoderParameters.from(accessTokenClaims)).getTokenValue();

        // 5. Gerar NOVO refresh token (rotação)
        var refreshTokenClaims = JwtClaimsSet.builder()
                .issuer("ai-powered-task-app")
                .subject(userId)
                .issuedAt(now)
                .expiresAt(now.plusSeconds(refreshTokenExpiresIn))
                .claim("type", "refresh")
                .build();

        var newRefreshToken = jwtEncoder.encode(JwtEncoderParameters.from(refreshTokenClaims)).getTokenValue();

        // 6. Atualizar banco com novo refresh token
        user.setRefreshToken(newRefreshToken);
        user.setRefreshTokenExpiryDate(now.plusSeconds(refreshTokenExpiresIn));
        userRepository.save(user);

        return new LoginResponse(newAccessToken, newRefreshToken, accessTokenExpiresIn, "Bearer");

    } catch (Exception e) {
        throw new InvalidCredentialsException("Invalid or expired refresh token");
    }
}

4.5 Endpoint no Controller

Lembre de adicionar /api/auth/refresh na lista de rotas públicas no SecurityConfig:

@PostMapping("/refresh")
public ResponseEntity<LoginResponse> refresh(
        @RequestHeader("Authorization") String authHeader) {

    String refreshToken = authHeader.replace("Bearer ", "");
    return ResponseEntity.ok(userService.refreshAccessToken(refreshToken));
}

5. Fluxo completo de autenticação

[Login]
POST /api/auth/login { email, password }
  └─> Retorna: { accessToken, refreshToken, expiresIn, tokenType }
[Usar a API]
GET /api/recurso-protegido
Header: Authorization: Bearer <accessToken>
  └─> Spring valida automaticamente via JwtDecoder (chave pública RSA)
  └─> Se válido: 200 OK
  └─> Se inválido/expirado: 401 Unauthorized
[Renovar access token]
POST /api/auth/refresh
Header: Authorization: Bearer <refreshToken>
  └─> Retorna: { novoAccessToken, novoRefreshToken, expiresIn, tokenType }
  └─> O refresh token antigo é invalidado (rotação)

6. Access Token vs Refresh Token

Access Token

  • Duração curta: 30 minutos
  • Usado em toda chamada à API no header Authorization: Bearer
  • Armazenado apenas no cliente (memória ou cookie seguro)
  • Não é rotacionado — apenas expira

Refresh Token

  • Duração longa: 7 dias
  • Usado somente para solicitar um novo access token
  • Armazenado no cliente e no banco de dados (para validação server-side)
  • É rotacionado a cada uso — o antigo é invalidado imediatamente

7. Pontos de atenção

  • Nunca commitar arquivos .key (chave privada) no repositório. Use variáveis de ambiente ou secrets managers em produção.
  • O JwtDecoder já valida expiração automaticamente — a verificação manual de refreshTokenExpiryDate no banco é uma camada adicional de segurança (útil para invalidar tokens antes do prazo, ex: logout).
  • Para logout, basta limpar o refreshToken do usuário no banco — o access token continuará válido até expirar naturalmente (por isso durações curtas são importantes).

Gostou deste artigo? Compartilhe!