JWT: usar corretamente e erros de segurança comuns

1. Visão geral

JWT (JSON Web Token) é usado em toda parte para autenticação de API, mas muitos sistemas implementam de forma incorreta causando vulnerabilidades graves. Este artigo resume como criar e verificar JWT corretamente em Spring Boot, juntamente com erros de segurança clássicos (confusão de alg, secret fraco, não verificar expiration…) e como evitá-los. Após ler, você saberá como configurar JWT com segurança e revisar código JWT de colegas com mais confiança.

2. Estrutura e mecanismo de funcionamento do JWT

JWT possui 3 partes: header.payload.signature, codificadas em base64url, separadas por pontos.

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.4Z... (signature)
  • Header: contém alg (algoritmo de assinatura) e typ.
  • Payload: contém claims como sub, exp, iat, roles.
  • Signature: assinatura para verificar que o token não foi alterado.

Atenção: JWT não encripta o payload, apenas codifica. Qualquer um pode ler o conteúdo se tiver o token. Não coloque senha, PII sensível nos claims.

3. Adicionando dependências

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>

A biblioteca jjwt (io.jsonwebtoken) é a mais popular para Java, com API clara e já bloqueia por padrão alguns erros comuns.

4. Criar e verificar token corretamente

4.1. Gerar uma secret key forte

import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;

public class JwtKeyFactory {

    public static SecretKey generateKey() {
        return Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
    }
}

Keys.secretKeyFor gera uma chave aleatória com comprimento suficiente para HS256 (≥ 256 bits). Em produção, armazene essa chave em um gerenciador de secrets (Vault, AWS Secrets Manager), não no código.

4.2. Criar token com TokenService

import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;
import java.time.Instant;
import java.util.Date;

public class TokenService {

    private final SecretKey key;

    public TokenService(SecretKey key) {
        this.key = key;
    }

    public String generateToken(String username) {
        Instant now = Instant.now();
        return Jwts.builder()
                .subject(username)
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plusSeconds(900))) // 15 minutos
                .signWith(key)
                .compact();
    }
}
  • expiration definido para 15 minutos — access token deve ter vida curta.
  • signWith(key) escolhe automaticamente o alg adequado ao tipo de chave, evitando que o cliente interfira no alg.

4.3. Verificar token

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;

public class TokenVerifier {

    private final SecretKey key;

    public TokenVerifier(SecretKey key) {
        this.key = key;
    }

    public Claims verify(String token) {
        try {
            return Jwts.parser()
                    .verifyWith(key)
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();
        } catch (JwtException e) {
            throw new InvalidTokenException("Token inválido ou expirado", e);
        }
    }
}

parseSignedClaims verifica automaticamente a signature e exp. Se a signature estiver errada ou o token expirar, ele lança JwtException — você a captura e converte em uma exceção do seu domínio.

5. Erro de segurança #1: alg=none ou alg confusion

Um atacante altera o header para {"alg":"none"} e envia um token sem signature. Se o backend não forçar um algoritmo, pode aceitar um token falso.

// ERRADO: não especificar qual algoritmo é permitido
Jwts.parser().build().parseSignedClaims(token); // bibliotecas antigas fáceis de enganar

Como evitar — sempre faça whitelist do algoritmo:

import io.jsonwebtoken.Jwts;

Jwts.parser()
        .verifyWith(key)          // aceita apenas chave conhecida
        .build()
        .parseSignedClaims(token); // jjwt 0.12+ bloqueia automaticamente alg=none

Atenção: Com outras bibliotecas JWT (jose4j, nimbus-jose-jwt), configure explicitamente o whitelist de algoritmos (por exemplo, apenas HS256). Nunca confie no campo alg no token enviado pelo cliente.

Uma variante mais perigosa: alg confusion entre RS256 e HS256. Se o servidor verificar com a chave pública RSA mas o código usar como secret para HMAC quando alg=HS256, um atacante pode assinar o token com a chave pública (que é pública). Solução: fixe o algoritmo conforme o caso de uso, não deixe o código ler alg do token e escolher como verificar.

6. Erro de segurança #2: secret fraco ou secret exposto

// ERRADO: secret curto, fácil de fazer brute-force
String secret = "mysecret123";
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());

HS256 requer chave ≥ 256 bits (32 bytes). Secret curto pode sofrer brute-force offline com ferramentas como hashcat em poucas horas.

// CORRETO: secret com comprimento adequado, aleatório
SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);

Além disso:

  • Não faça commit do secret no Git. Use variáveis de ambiente ou gerenciador de secrets.
  • Alterne o secret periodicamente, especialmente após funcionários com acesso deixarem a empresa.
# application.properties - lê da variável de ambiente, não hardcode
jwt.secret=${JWT_SECRET}

7. Erro de segurança #3: não verificar expiration ou token com vida muito longa

// ERRADO: access token vive 30 dias
.expiration(Date.from(now.plus(30, ChronoUnit.DAYS)))

Access token com vida muito longa significa que se for vazado, o atacante tem acesso prolongado que você não consegue revogar (JWT é stateless, não tem mecanismo de logout padrão).

Padrão correto: access token curto (5–15 minutos) + refresh token mais longo, armazenado no servidor (ou rotacionado) para permitir revogação.

public class TokenPair {
    private final String accessToken;   // vive 15 minutos
    private final String refreshToken;  // vive 7 dias, armazenado em BD para revogação

    // construtor, getters
}
public class RefreshTokenRepository {

    public boolean isRevoked(String refreshTokenId) {
        // consulta BD/Redis, se o token foi revogado (logout) retorna true
        return false;
    }
}

Quando um refresh token é revogado, o access token antigo continua vivendo até expirar — por isso o access token deve ser curto.

8. Erro de segurança #4: incluir dados sensíveis no payload

// ERRADO: payload é público, quem tem o token consegue ler
.claim("password", user.getPassword())
.claim("creditCard", user.getCardNumber())

O payload apenas codifica em base64, não encripta. Decodificar em jwt.io faz você ver tudo. Coloque apenas claims necessários para autorização: sub, roles, exp, scope.

// CORRETO
.subject(user.getUsername())
.claim("roles", user.getRoles())

9. Erro de segurança #5: não validar issuer/audience ao compartilhar secret entre múltiplos serviços

Se vários serviços compartilham o mesmo secret para assinar tokens, um token emitido para Service A pode sofrer replay no Service B se não verificar aud (audience).

import io.jsonwebtoken.Jwts;

Claims claims = Jwts.parser()
        .verifyWith(key)
        .requireAudience("order-service") // aceita apenas token destinado a este serviço
        .requireIssuer("auth-server")
        .build()
        .parseSignedClaims(token)
        .getPayload();

requireAudience e requireIssuer forçam o parser a lançar exceção se o claim não corresponder — evita que o token seja usado para propósito errado.

10. Integração com Spring Security filter

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

public class JwtAuthFilter extends OncePerRequestFilter {

    private final TokenVerifier verifier;

    public JwtAuthFilter(TokenVerifier verifier) {
        this.verifier = verifier;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) throws IOException, ServletException {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            try {
                var claims = verifier.verify(header.substring(7));
                var auth = new UsernamePasswordAuthenticationToken(
                        claims.getSubject(), null, List.of());
                SecurityContextHolder.getContext().setAuthentication(auth);
            } catch (InvalidTokenException e) {
                SecurityContextHolder.clearContext();
            }
        }
        chain.doFilter(request, response);
    }
}
  • Extrai o token do header Authorization: Bearer <token>.
  • Verifica com TokenVerifier criado na seção 4.3.
  • Se o token for inválido, limpa o contexto — a requisição será bloqueada pelo Spring Security no próximo passo de autorização, não lança exceção diretamente no filter.

Atenção: Não armazene JWT em localStorage se o frontend é uma SPA pública — é vulnerável a XSS e roubo de token. Prefira armazenar em cookie HttpOnly, Secure, SameSite=Strict quando possível.

11. Testando o comportamento de verificação de token

import io.jsonwebtoken.security.Keys;
import org.junit.jupiter.api.Test;
import javax.crypto.SecretKey;
import java.time.Instant;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;

class TokenVerifierTest {

    private final SecretKey key = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
    private final TokenService tokenService = new TokenService(key);
    private final TokenVerifier verifier = new TokenVerifier(key);

    @Test
    void shouldVerifyValidToken() {
        String token = tokenService.generateToken("alice");

        var claims = verifier.verify(token);

        assertThat(claims.getSubject()).isEqualTo("alice");
    }

    @Test
    void shouldRejectTokenSignedWithDifferentKey() {
        SecretKey otherKey = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
        TokenService fakeService = new TokenService(otherKey);
        String forgedToken = fakeService.generateToken("attacker");

        assertThatThrownBy(() -> verifier.verify(forgedToken))
                .isInstanceOf(InvalidTokenException.class);
    }
}

O segundo teste simula corretamente a situação onde um atacante assina um token com uma chave diferente — o verificador deve rejeitar, nunca aceitar silenciosamente.

shouldVerifyValidToken()                 PASS
shouldRejectTokenSignedWithDifferentKey() PASS

12. Checklist rápido antes de fazer deploy

  • Secret ≥ 256 bits, armazenado em gerenciador de secrets, sem hardcode.
  • Whitelist de algoritmos, não confie em alg do token.
  • Access token curto (≤ 15 minutos), com refresh token para revogação.
  • Não inclua dados sensíveis no payload.
  • Valide exp, iss, aud quando necessário.
  • Armazene token em cookie HttpOnly em vez de localStorage para SPA.
  • Tenha mecanismo de revogação/blacklist para refresh token.

13. Conclusão

JWT em si não tem defeito — a maioria dos problemas vem da forma como é implementado: secret fraco, confiança em alg do cliente, token com vida muito longa, ou dados sensíveis no payload público. Aplicando o checklist da seção 12 e sempre usando uma biblioteca JWT bem testada (jjwt, nimbus-jose-jwt) em vez de implementar seu próprio parser, você evita a maioria das vulnerabilidades comuns. O próximo passo a estudar: OAuth 2.0 / OpenID Connect para melhor gerenciar o ciclo de vida de tokens em escala com múltiplos serviços.

jwtweb-securityspring-bootauthenticationjava