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.keycd src/main/resources
openssl rsa -in app.key -pubout -out app.pubNo 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.keyHMAC256 (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 (
expclaim) issuedAt(iatclaim)- 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
JwtDecoderjá valida expiração automaticamente — a verificação manual derefreshTokenExpiryDateno banco é uma camada adicional de segurança (útil para invalidar tokens antes do prazo, ex: logout). - Para logout, basta limpar o
refreshTokendo 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!