Sécuriser REST API avec Spring Security et JWT
1. Aperçu
Une REST API ne conserve généralement pas de session. Chaque requête doit porter ses propres informations d’authentification, et JWT (JSON Web Token) est le moyen le plus courant d’y parvenir.
Cet article vous guide pour sécuriser une API de gestion des commandes avec Spring Security 6 et Spring Boot 3. Nous allons émettre un JWT lorsqu’un utilisateur se connecte, authentifier le token à chaque requête et contrôler les permissions selon le rôle. La dernière partie couvre les tests automatisés des comportements principaux.
Nous n’écrirons pas manuellement de filtre JWT. À la place, nous utilisons OAuth2 Resource Server déjà fourni dans Spring Security. Cette approche nécessite moins de code et est plus sûre.
2. Dépendances Maven
Vous avez besoin des dépendances suivantes dans 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>
Le starter oauth2-resource-server apporte la bibliothèque Nimbus JOSE + JWT. Cette bibliothèque gère la signature et la vérification de la signature des tokens. Vous n’avez pas besoin d’ajouter jjwt ou une autre bibliothèque JWT.
3. Configuration de la clé secrète
Pour simplifier, nous signons le token avec l’algorithme symétrique HS256. La même clé secrète est utilisée pour signer et vérifier la signature.
jwt.secret=${JWT_SECRET:remplacez-ceci-par-une-cle-secrete-d-au-moins-32-bytes}
jwt.expiration-minutes=15
L’expression ${JWT_SECRET:...} lit la clé depuis la variable d’environnement JWT_SECRET. Si cette variable n’existe pas, Spring utilise la valeur par défaut après les deux-points. La valeur par défaut n’est que pour l’environnement de développement.
Attention : HS256 nécessite une clé d’au minimum 256 bits, soit 32 bytes. Si la clé est plus courte, Nimbus lèvera
KeyLengthExceptionau premier appel de signature du token.
4. Configuration de Spring Security
4.1. JwtEncoder, JwtDecoder et utilisateurs
@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);
}
}
Le rôle de chaque bean :
SecretKeyenveloppe la chaîne secrète comme une clé HMAC-SHA256.JwtEncoderutilise cette clé pour signer le token quand l’utilisateur se connecte.JwtDecodervérifie la signature et l’expiration (exp) du token à chaque requête. Resource Server l’utilise automatiquement.InMemoryUserDetailsManagercrée deux utilisateurs exemples. En production, vous le remplaceriez par une implémentation qui lit depuis la base de données.AuthenticationManagervérifie le nom d’utilisateur et le mot de passe au point de terminaison de connexion.
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;
}
Ce bean se trouve dans la même classe SecurityConfig. Chaque partie fait ce qui suit :
csrf.disable(): l’API n’utilise pas de cookies pour l’authentification, donc ne subit pas d’attaques CSRF.STATELESS: Spring ne crée pasHttpSession. Chaque requête est réauthentifiée via le token.authorizeHttpRequests: le point de terminaison de connexion est public./api/admin/**est réservé au rôleADMIN. Tous les autres points de terminaison nécessitent une authentification.oauth2ResourceServer(...jwt...): active le filtre qui lit l’en-têteAuthorization: Bearer <token>et décode le token avecJwtDecoder.
Par défaut, Spring lit les permissions du claim scope et ajoute le préfixe SCOPE_. Nous voulons utiliser hasRole("ADMIN"), donc JwtAuthenticationConverter est configuré pour lire le claim roles. Le préfixe est supprimé car la valeur dans le token est déjà au format ROLE_ADMIN.
5. Émettre un token lors de la connexion
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;
}
}
Le token contient les claims suivants : l’émetteur (iss), le moment d’émission (iat), le moment d’expiration (exp), le nom d’utilisateur (sub) et la liste des rôles.
La ligne JwsHeader.with(MacAlgorithm.HS256) est obligatoire. Sans elle, NimbusJwtEncoder choisit par défaut RS256. Dans ce cas, il ne trouve pas de clé appropriée et lève 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() vérifie le nom d’utilisateur et le mot de passe via DaoAuthenticationProvider. S’ils sont incorrects, la méthode lève BadCredentialsException. Spring Security traduit cette erreur en HTTP 401. S’ils sont corrects, le contrôleur crée le token et le retourne au client.
Essayez avec 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. Points de terminaison protégés
@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 injecte le token authentifié dans le contrôleur. Vous récupérez le nom d’utilisateur avec jwt.getSubject(). Pour lire un autre claim, utilisez jwt.getClaim("roles").
Essayez dans trois scénarios différents :
# Aucun token
curl -i localhost:8080/api/orders
# alice avec un token valide
curl -s localhost:8080/api/orders -H "Authorization: Bearer $TOKEN"
# alice appelle le point de terminaison 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", ...
Le résultat montre deux codes d’erreur différents. 401 signifie non authentifié : token manquant, mauvaise signature ou token expiré. 403 signifie authentifié mais permissions insuffisantes.
7. Rédiger les tests
spring-security-test fournit un post-processeur jwt(). Il simule un JWT déjà authentifié sans avoir besoin de signer le token réellement. Nous le combinons avec un test end-to-end qui inclut une connexion réelle.
@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);
}
}
Les imports statiques nécessaires sont MockMvcRequestBuilders.*, SecurityMockMvcRequestPostProcessors.jwt et Assertions.assertThat d’AssertJ.
Le deuxième test couvre le flux complet. Il appelle le point de terminaison de connexion, récupère le token, puis l’utilise pour appeler l’API. Ce test détecte les erreurs de configuration de l’encodeur et du décodeur, comme oublier JwsHeader ou une clé trop courte.
Le dernier test utilise jwt() pour tester isolément la partie contrôle des permissions. Notez que .authorities(...) remplace le convertisseur, vous devez donc passer le nom correct de l’autorité ROLE_CUSTOMER.
8. Erreurs courantes et quand ne pas utiliser cette approche
Erreurs fréquentes :
- Clé trop courte : une clé inférieure à 32 bytes provoque
KeyLengthExceptionavec HS256. - Oublier le header HS256 :
JwtEncodersignaleFailed to select a JWK signing key. - Mauvais préfixe d’autorité : si vous laissez le convertisseur par défaut, les permissions ont la forme
SCOPE_.... Dans ce cas,hasRole("ADMIN")retourne toujours 403. Vérifiez que le claim et le préfixe correspondent. - Clé secrète en dur dans le repo : toujours lire la clé depuis une variable d’environnement ou un gestionnaire de secrets.
- Placer des données sensibles dans le payload : le payload JWT n’est que codé en Base64URL, pas chiffré. N’importe qui ayant le token peut lire le contenu. Ne mettez pas de mots de passe, numéros de carte ou informations personnelles dans le token.
Note : JWT ne peut pas être révoqué avant expiration sans ajouter un mécanisme supplémentaire comme une liste noire. Gardez l’access token de courte durée (5–15 minutes). Pour les sessions longues, ajoutez un refresh token stocké côté serveur pour pouvoir le révoquer.
Quand ne pas utiliser cette approche :
- Application web rendue côté serveur, comme Thymeleaf : les sessions avec cookies sont plus simples et plus sûres.
- Besoin de déconnexion ou verrou de compte effectif immédiatement : les sessions ou tokens opaques avec introspection sont mieux adaptés.
- Système multi-services : partager une même clé HMAC entre tous les services est très risqué. Utilisez un serveur d’autorisation séparé, comme Spring Authorization Server ou Keycloak. Le serveur d’autorisation signe les tokens avec RSA ou EC, et les services configurent uniquement
spring.security.oauth2.resourceserver.jwt.issuer-uripour récupérer la clé publique via JWKS.
9. Conclusion
Cet article a construit une API REST stateless sécurisée avec JWT, en suivant ces étapes :
- Utiliser
NimbusJwtEncoderetNimbusJwtDecoderavec une clé HS256. - Émettre le token au point de terminaison
/api/auth/loginviaAuthenticationManager. - Laisser OAuth2 Resource Server authentifier automatiquement l’en-tête
Bearer. - Mapper le claim
rolesaux autorités pour utiliserhasRole. - Vérifier les cas 401, 403 et le flux de succès avec MockMvc et AssertJ.
À partir de cette base, vous pouvez remplacer InMemoryUserDetailsManager par des utilisateurs stockés en base de données, ajouter un refresh token, ou passer à des clés asymétriques quand le système s’étend sur plusieurs services.