JWT : utilisation correcte et erreurs de sécurité courantes
1. Présentation générale
JWT (JSON Web Token) est utilisé partout pour authentifier les API, mais de nombreux systèmes l’implémentent mal et créent des failles graves. Cet article synthétise comment créer et vérifier JWT correctement dans Spring Boot, en couvrant les erreurs de sécurité classiques (alg confusion, secret faible, non-vérification de l’expiration…) et comment les éviter. Après lecture, vous saurez configurer JWT en toute sécurité et examiner le code JWT de vos collègues avec plus de confiance.
2. Structure de JWT et mécanisme de fonctionnement
JWT est composé de 3 parties : header.payload.signature, encodées en base64url, séparées par des points.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.4Z... (signature)
- Header : contient
alg(algorithme de signature) ettyp. - Payload : contient des claims comme
sub,exp,iat,roles. - Signature : la signature pour vérifier que le token n’a pas été modifié.
Attention : JWT n’encrypte pas le payload, il l’encode seulement. N’importe qui peut lire le contenu s’il possède le token. Ne mettez pas de mot de passe, d’informations personnelles sensibles dans les claims.
3. Ajouter les dépendances
<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>
La bibliothèque jjwt (io.jsonwebtoken) est la plus populaire pour Java, avec une API claire et par défaut elle bloque certaines erreurs courantes.
4. Créer et vérifier un token correctement
4.1. Générer une clé secrète suffisamment 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 génère une clé aléatoire de longueur suffisante pour HS256 (≥ 256 bits). En production, stockez cette clé dans un gestionnaire de secrets (Vault, AWS Secrets Manager), ne la mettez pas en dur dans le code.
4.2. Créer un token avec 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 minutes
.signWith(key)
.compact();
}
}
expirationdéfini à 15 minutes — un access token doit avoir une courte durée de vie.signWith(key)choisit automatiquement l’algorithme approprié selon le type de clé, empêchant le client de modifieralg.
4.3. Vérifier un 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 invalide ou expiré", e);
}
}
}
parseSignedClaims vérifie automatiquement la signature et l’exp. Si la signature est incorrecte ou que le token a expiré, il lève JwtException — vous pouvez l’attraper et la convertir en exception métier.
5. Faille de sécurité #1 : alg=none ou alg confusion
Un attaquant modifie le header en {"alg":"none"} puis envoie un token sans signature. Si le backend n’impose pas l’algorithme, il peut accepter un token contrefait.
// MAUVAIS : algorithme non spécifié
Jwts.parser().build().parseSignedClaims(token); // anciennes versions de la bibliothèque peuvent être trompées
Comment prévenir — toujours whitelist l’algorithme :
import io.jsonwebtoken.Jwts;
Jwts.parser()
.verifyWith(key) // accepte uniquement la clé connue
.build()
.parseSignedClaims(token); // jjwt 0.12+ bloque automatiquement alg=none
Attention : Avec d’autres bibliothèques JWT (jose4j, nimbus-jose-jwt), configurez toujours le whitelist d’algorithme explicitement (par exemple, autorisez uniquement
HS256). Ne faites jamais confiance au champalgdans le token envoyé par le client.
Une variante plus dangereuse : alg confusion entre RS256 et HS256. Si le serveur vérifie avec une clé publique RSA mais que le code l’utilise comme secret pour HMAC quand alg=HS256, un attaquant peut signer lui-même le token avec la clé publique (qui est publique). Solution : fixez l’algorithme selon le cas d’usage, ne laissez pas le code lire alg depuis le token et choisir la méthode de vérification en conséquence.
6. Faille de sécurité #2 : secret faible ou exposé
// MAUVAIS : secret court, facile à brute-forcer
String secret = "mysecret123";
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());
HS256 exige une clé ≥ 256 bits (32 bytes). Un secret court peut être brute-forcé hors ligne avec des outils comme hashcat en quelques heures.
// BON : secret suffisamment long, aléatoire
SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
En outre :
- Ne committez jamais le secret dans Git. Utilisez des variables d’environnement ou un gestionnaire de secrets.
- Faites tourner le secret régulièrement, surtout quand quelqu’un ayant accès au secret quitte l’équipe.
# application.properties - lire depuis env, ne pas mettre en dur
jwt.secret=${JWT_SECRET}
7. Faille de sécurité #3 : non-vérification de l’expiration ou token vivant trop longtemps
// MAUVAIS : access token vivant 30 jours
.expiration(Date.from(now.plus(30, ChronoUnit.DAYS)))
Un access token vivant trop longtemps signifie que s’il est exposé, un attaquant a un accès à long terme que vous ne pouvez pas révoquer (JWT est sans état, pas de mécanisme de logout par défaut).
Bon pattern : access token court (5–15 minutes) + refresh token plus long, stocké sur le serveur (ou rotaté) pour pouvoir être révoqué.
public class TokenPair {
private final String accessToken; // vivant 15 minutes
private final String refreshToken; // vivant 7 jours, stocké en DB pour pouvoir être révoqué
// constructor, getters
}
public class RefreshTokenRepository {
public boolean isRevoked(String refreshTokenId) {
// interroger DB/Redis, retourner true si le token a été déconnecté
return false;
}
}
Quand un refresh token est révoqué, l’ancien access token vit toujours jusqu’à son expiration — c’est pourquoi l’access token doit être court.
8. Faille de sécurité #4 : mettre des données sensibles dans le payload
// MAUVAIS : le payload est public, n'importe qui avec le token peut le lire
.claim("password", user.getPassword())
.claim("creditCard", user.getCardNumber())
Le payload est seulement encodé en base64, pas chiffré. En utilisant jwt.io pour décoder, on voit tout. Vous ne devez inclure que les claims nécessaires pour l’autorisation : sub, roles, exp, scope.
// BON
.subject(user.getUsername())
.claim("roles", user.getRoles())
9. Faille de sécurité #5 : ne pas valider l’émetteur/audience quand plusieurs services partagent le même secret
Si plusieurs services utilisent le même secret pour signer les tokens, un token émis pour le Service A peut être rejoué vers le Service B s’il n’y a pas de vérification de l’aud (audience).
import io.jsonwebtoken.Jwts;
Claims claims = Jwts.parser()
.verifyWith(key)
.requireAudience("order-service") // accepte uniquement les tokens destinés à ce service
.requireIssuer("auth-server")
.build()
.parseSignedClaims(token)
.getPayload();
requireAudience et requireIssuer forcent le parser à lever une exception si la claim ne correspond pas — cela empêche le token d’être utilisé à mauvais escient.
10. Intégration avec un filtre Spring Security
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);
}
}
- Extrait le token du header
Authorization: Bearer <token>. - Vérifie avec le
TokenVerifierécrit à la section 4.3. - Si le token est incorrect, efface le contexte — la requête sera bloquée par Spring Security à l’étape d’autorisation suivante, sans lever d’exception directement dans le filtre.
Attention : Ne stockez pas les JWT dans
localStoragesi le frontend est une SPA publique — ils sont facilement volés par XSS. Préférez les stocker dans des cookiesHttpOnly,Secure,SameSite=Strictquand c’est possible.
11. Tester le comportement de vérification du 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);
}
}
Le deuxième test simule exactement le scénario où un attaquant signe lui-même le token avec une autre clé — verifier doit rejeter, ne doit pas accepter silencieusement.
shouldVerifyValidToken() PASS
shouldRejectTokenSignedWithDifferentKey() PASS
12. Checklist rapide avant le déploiement
- Secret ≥ 256 bits, stocké dans un gestionnaire de secrets, pas de valeur en dur.
- Whitelist l’algorithme, ne faites pas confiance à
algdu token. - Access token court (≤ 15 minutes), avec un refresh token pour la révocation.
- Ne mettez pas de données sensibles dans le payload.
- Validez
exp,iss,audselon les besoins. - Stockez le token dans un cookie
HttpOnlyplutôt que danslocalStoragequand le frontend est une SPA. - Ayez un mécanisme de révocation/blacklist pour les refresh tokens.
13. Conclusion
JWT lui-même n’a pas de failles — la plupart des problèmes viennent de son implémentation : secret faible, confiance dans alg du client, token vivant trop longtemps, ou mise de données sensibles dans le payload public. En appliquant la checklist de la section 12 et en utilisant toujours une bibliothèque JWT éprouvée (jjwt, nimbus-jose-jwt) plutôt que d’écrire votre propre parser, vous éviterez la plupart des failles courantes. L’étape suivante à explorer : OAuth 2.0 / OpenID Connect pour une meilleure gestion du cycle de vie des tokens à l’échelle de plusieurs services.