1. Stateless et Bearer token
Si vous êtes habitué au monde session-based, tout y est plutôt confortable : vous vous connectez une fois, le serveur place l'« état de connexion » dans HttpSession, le navigateur stocke JSESSIONID, et ensuite les requêtes « comme par magie » sont considérées comme provenant du même utilisateur. Dans le modèle stateless, cette aisance s'arrête, non pas parce que Spring Security est méchant, mais parce que nous avons nous‑mêmes demandé : « serveur, ne stocke pas l'état d'auth entre les requêtes ».
De là naît une idée simple mais déroutante : chaque requête protégée doit apporter son propre contexte d'authentification. Pour notre cours, ce contexte est le Bearer token, que le client place dans l'en‑tête HTTP Authorization. Et si la requête n'apporte pas de token, le serveur n'a littéralement rien à partir duquel « reconstruire » l'utilisateur actuel.
Pour ne pas vous perdre, il est utile d'avoir en tête un petit schéma :
flowchart TD
C["Client"] -->|Authorization: Bearer ...| API["Secure Content Platform API"]
API --> SC["Security layer / filter chain"]
SC --> MVC["Controllers & Services"]
Dans le modèle session-based, il y avait une « mémoire » (HttpSession) entre l'API et la couche de sécurité. Dans le modèle stateless, il n'y a pas de mémoire, donc la seule chance de retrouver l'utilisateur courant est d'examiner la requête et d'y trouver un token.
2. L'en‑tête Authorization : contrat
L'en‑tête Authorization pourrait faire croire qu'il est unique et qu'il ne concerne que l'« autorisation ». En pratique, c'est simplement un point d'entrée standard HTTP où le client dépose la preuve qu'il peut représenter cet utilisateur. C'est ensuite votre backend qui décide comment interpréter cette preuve. Nous avons déjà vu HTTP Basic, et là aussi Authorization était présent — avec un contenu différent.
Voici à quoi cela ressemble :
| Modèle | Exemple d'en‑tête | Ce qu'il y a dedans (en langage humain) |
|---|---|---|
| Basic | Authorization: Basic dXNlcjpwYXNz | base64 de username:password (codage, pas chiffrement) |
| Bearer | Authorization: Bearer eyJhbGciOi... | token d'accès (généralement JWT) à vérifier |
Il est important de ne pas confondre les rôles : l'en‑tête Authorization est l'« enveloppe ». Mais la « lettre à l'intérieur » peut être Basic, Bearer ou des dizaines d'autres schémas. C'est pourquoi la première chose que nous devons apprendre à faire est de ne pas attraper n'importe quel Authorization comme s'il s'agissait d'un JWT, mais de vérifier : est‑ce bien un Bearer ou pas ?
Et oui, une petite remarque : le nom de l'en‑tête en HTTP est insensible à la casse, mais en Java nous écrivons généralement de manière canonique "Authorization". Le conteneur Servlet le comprend aussi bien comme ça.
3. Format Authorization: Bearer ...
Voici un moment « apparemment évident, mais c'est là qu'on trébuche le plus souvent ». Un Bearer token en HTTP se présente ainsi : l'en‑tête Authorization contient une chaîne commençant par le préfixe Bearer (attention à l'espace), puis le token lui‑même. Le mot Bearer ne fait pas référence à l'ours, même si cela donne l'impression qu'on va au zoo. Bearer signifie littéralement « porteur » : celui qui présente le token sera considéré comme l'utilisateur (après vérification du token, bien sûr).
Par exemple, pour GET /api/me, notre client doit envoyer :
# -i : afficher le statut/en‑têtes de réponse pour voir 200/401 en direct
# -H : transmettre Authorization strictement au format "Bearer {token}"
curl -i http://localhost:8080/api/me \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
Et s'il n'y a pas de token, le client n'ajoute tout simplement pas cet en‑tête. C'est là qu'intervient la logique clé : l'absence d'en‑tête n'est pas un « token cassé », c'est simplement l'absence d'un contexte d'authentification. Pour un endpoint public, c'est normal. Pour un endpoint protégé, cela conduira à un 401, non pas parce que le token est cassé, mais parce qu'il n'y en a pas.
Pour l'étape d'extraction, une seule distinction nous suffit : l'absence d'en‑tête Bearer n'est pas encore un « token invalide ». Pour l'instant, nous répondons simplement à la question de savoir s'il existe une chaîne de token avec laquelle travailler ; la vérification de la signature, de la durée de vie et de la raison précise du refus relève déjà de l'étape de validation.
4. Extraction du token : uniquement le parsing
Parfois, un débutant (et pas seulement un débutant) voit JWT et pense : « Oh, il y a des rôles dedans ! Je vais les lire et décider si j'autorise ou non. » À ce moment, la sécurité est triste. Parce qu'avant de vérifier la signature et la durée de vie, le token n'est qu'une simple chaîne provenant du client, un peu comme un corps JSON ou un paramètre de requête. Si vous n'avez pas vérifié la signature, un attaquant peut modifier le payload comme il le souhaite et se « rajouter » un rôle ADMIN.
C'est pourquoi nous divisons délibérément le processus en étapes. Aujourd'hui, c'est la toute première étape, la plus « bête » mais extrêmement importante : extraire la chaîne du token de la requête HTTP. À cette étape, nous nous contentons de parser l'en‑tête, de vérifier soigneusement le préfixe Bearer , de retourner la chaîne du token ou null, et c'est tout. Nous ne construisons pas de Authentication, ne créons pas de principal, ne vérifions pas les autorités, n'allons pas en base de données.
Cette discipline est également utile parce qu'elle crée des « points d'arrêt » lisibles dans le code : quand quelque chose ne fonctionne pas, vous comprenez rapidement si le problème vient du fait que le token n'a pas été transmis, ou bien qu'il a été transmis mais qu'il ne passe pas la validation.
5. Limites du JWT‑filtre par URL
Un filtre est un outil puissant : il peut s'exécuter sur chaque requête. Et si vous utilisez cette puissance « n'importe comment », vous allez vite vous créer un petit enfer. Par exemple, vous allez soudainement exiger un token sur /api/auth/login ou /api/auth/register (là où le token est justement délivré ou l'utilisateur créé), ou vous allez essayer de parser un Bearer token sur /api/public/articles, là où il n'est pas nécessaire.
Dans notre projet Secure Content Platform API, il est pratique de résumer cela à quelques zones :
| Zone | URL | Signification |
|---|---|---|
| Publique | /api/public/** | un anonyme peut lire les articles |
| Zone d'auth | /api/auth/** | login, inscription et autres endpoints d'auth |
| Protégée | /api/me/**, , , |
ici l'authentification Bearer est nécessaire |
| Hors API | pas /api/** | le filtre JWT n'y va pas |
Pour ce passage, une règle simple suffit : le filtre ne doit pas « gêner » les endpoints publics et d'auth. Même si techniquement il pourrait s'exécuter là aussi, la vie sera plus simple si le filtre ne travaille que là où le Bearer est vraiment nécessaire.
Une vérification de zone très simple pourrait ressembler à ceci :
import jakarta.servlet.http.HttpServletReqest;
private boolean isProtectedApi(HttpServletRequest request) {
// On prend l'URI sans domaine ni paramètres de requête (ex. "/api/me")
String uri = request.getRequestURI();
// L'idée est simple : l'authentification Bearer n'est nécessaire qu'à l'intérieur de /api/...
// et n'est pas nécessaire dans les zones public/auth
return uri.startsWith("/api/")
&& !uri.startsWith("/api/public/") // public : token non obligatoire
&& !uri.startsWith("/api/auth/"); // auth : ici le token n'existe souvent pas encore (login/inscription)
}
Oui, ce n'est pas l'implémentation « idéale » finale. Mais sous cette forme, les limites sont immédiatement visibles, et c'est ce qui importe le plus ici. Cela suffit pour respecter le principe principal : le filtre JWT n'est pas « pour tout ce qui bouge », mais pour l'API protégée.
6. BearerTokenResolver.resolve(...)
Le code le plus simple pour extraire un Bearer token ressemble presque à un « if à deux conditions ». Et c'est là sa force : il est compréhensible, difficile à casser, et il ne fait pas semblant d'avoir « tout prévu ». Dans un projet réel, vous commencerez souvent par un tel code, puis vous l'améliorerez pour les cas particuliers (espaces supplémentaires, casses différentes, clients étranges).
Dans le contexte de notre application, il est pratique de placer cette fonction dans le package security/jwt, afin que le filtre JWT puisse simplement l'appeler. Par exemple, créons une petite classe utilitaire.
package com.example.securecontent.security.jwt;
import jakarta.servlet.http.HttpServletRequest;
public final class BearerTokenResolver {
// Préfixe strictement avec un espace : "Bearer " + token
private static final String PREFIX = "Bearer ";
private BearerTokenResolver() {
// Classe utilitaire : pas d'instances
}
public static String resolve(HttpServletRequest request) {
// Récupère l'en-tête Authorization (s'il est absent — retourne null)
String header = request.getHeader("Authorization");
// Important : Authorization peut ne pas être Bearer (par exemple Basic)
if (header == null || !header.startsWith(PREFIX)) {
// « Pas de token » — c'est une situation normale, pas une exception
return null;
}
// On coupe seulement le préfixe, on obtient la chaîne « brute » du token
return header.substring(PREFIX.length());
}
}
Plusieurs choses sont importantes ici, qui ne sont pas évidentes pour un débutant. Nous retournons null, pas une exception, parce que « token absent » est une situation normale pour les requêtes publiques et les scénarios anonymes. Nous vérifions avec startsWith("Bearer ") car Authorization peut être Basic, alors que nous n'avons besoin que du Bearer. Enfin, nous retirons le préfixe par sa longueur, sans utiliser split(" ") — cela évite les surprises avec les « espaces supplémentaires ».
Si l'on veut être « un peu plus soigneux » (par exemple, supprimer les espaces éventuels sur les bords), on peut ajouter trim() :
// trim() : enlève les espaces aux extrémités si le client a envoyé "Bearer token "
String token = header.substring(PREFIX.length()).trim();
// isBlank() : on se protège du cas où après "Bearer " il n'y a rien du tout
return token.isBlank() ? null : token;
Mais ici, il vaut mieux commencer par le minimalisme. Il est préférable d'ajouter la complexité seulement quand elle est vraiment nécessaire.
7. Si pas de token : sortie précoce
Quand on commence à écrire du code de sécurité, la main a tendance à faire : « pas de token → tout de suite un 401 ! ». Et c'est là que nous nous rappelons que nous avons des endpoints publics. Si nous exigeons un token partout, nous allons simplement casser /api/public/articles, et avec lui l'idée de « zone publique ».
La logique correcte dans un flux JWT stateless est généralement la suivante : si le token n'est pas trouvé, nous n'authentifions pas l'utilisateur, mais nous ne cassons pas la requête non plus. Nous la laissons simplement passer. Ensuite, les règles d'accès (authorizeHttpRequests) décideront si authenticated() ou permitAll() est nécessaire. Si nécessaire, Spring Security retournera lui‑même un 401 via votre AuthenticationEntryPoint (que nous avons créé dans le bloc REST-friendly).
Même sans filtre complet, on peut voir le « noyau » de ce comportement :
// On tente d'extraire le Bearer token de la requête
String token = BearerTokenResolver.resolve(request);
if (token == null) {
// Pas de token : on N'authentifie PAS, on N'interdit PAS, on continue simplement la chaîne
filterChain.doFilter(request, response);
return;
}
// Si token n'est pas null — ensuite validation et construction de l'Authentication
Notez le moment psychologique : nous ne prenons pas ici de « décision d'accès ». Nous décidons simplement « y a‑t‑il des données pour tenter de reconstruire l'utilisateur ». Cela réduit considérablement le risque de commettre l'erreur typique : transformer le filtre JWT en un mélange d'authentification, d'autorisation et de logique métier.
Pour se faire une idée concrète, imaginons deux requêtes :
Endpoint public sans en‑tête :
# Zone publique : sans token, c'est normal
curl -i http://localhost:8080/api/public/articles
# attendu : 200 OK (sans token, c'est normal)
Endpoint protégé sans en‑tête :
# Zone protégée : sans token, l'utilisateur reste anonyme
curl -i http://localhost:8080/api/me
# attendu : 401 Unauthorized (l'utilisateur n'est pas authentifié)
Et ici, il est très important que l'extraction JWT se comporte de la même manière dans les deux cas : « pas de Bearer token → on n'authentifie pas → on continue ». La différence entre 200 et 401 sera faite par les règles d'accès et la gestion du cas non authentifié, et non par une « panique manuelle » à l'intérieur du filtre.
8. Erreurs typiques lors du travail avec Authorization: Bearer ...
Erreur n°1 : parser n'importe quel Authorization comme un Bearer token.
C'est un classique après une journée sur HTTP Basic. Hier vous envoyiez Authorization: Basic ..., aujourd'hui Authorization: Bearer ..., et demain quelqu'un apportera Authorization: Digest ... ou même un format personnalisé. C'est pourquoi la première condition de l'extraction est la vérification du préfixe Bearer . Si vous « avalez » n'importe quel Authorization, vous obtiendrez à coup sûr des erreurs étranges et un débogage très amusant.
Erreur n°2 : considérer l'absence d'en‑tête comme un « token invalide ».
L'absence de Authorization est simplement un scénario anonyme. Il doit fonctionner tranquillement dans la zone publique. Et dans la zone protégée, il doit conduire à un 401, mais ce n'est pas un problème de « token invalide », c'est un problème de « pas d'authentification ». Si vous mélangez ces cas, vous perdez une clarté diagnostique très importante : qu'est‑ce qui a exactement cassé chez le client ? A‑t‑il oublié d'ajouter l'en‑tête ou a‑t‑il ajouté un mauvais token ?
Erreur n°3 : extraire le token dans le contrôleur (@RequestHeader) et vivre « tant que ça marche ».
Cela semble pratique : « ben je peux récupérer @RequestHeader("Authorization") dans mon @GetMapping ». Mais ainsi vous commencez à disperser la couche de sécurité dans toute l'application. Quelque part vous oublierez, ailleurs vous ferez différemment, ailleurs vous vérifierez les rôles à la main… et soudainement vous vous retrouvez non pas avec Spring Security, mais avec un « Spring Insecurity maison ». Dans un modèle stateless, l'extraction de token doit être dans la couche de sécurité, avant les contrôleurs.
Erreur n°4 : rendre l'extraction JWT globale pour toutes les URL sans zone de responsabilité.
Techniquement, ce n'est pas toujours « fatal », mais cela empire presque toujours la vie : le filtre commence à toucher les endpoints d'auth, les ressources publiques, éventuellement /error, etc. Il devient difficile de comprendre pourquoi soudainement « tout est en 401 ». Même une simple fonction isProtectedApi(...) apporte déjà de l'ordre dans l'esprit : le filtre travaille là où nous attendons une authentification Bearer.
Erreur n°5 : faire confiance au token immédiatement après l'extraction et lire les claims.
Si après substring(7) vous vous empressez d'extraire les rôles et de prendre des décisions « autoriser/interdire », vous avez sauté l'idée principale de JWT : on peut lire le payload, mais on ne peut pas lui faire confiance tant que la signature et la durée de vie n'ont pas été vérifiées. La discipline « extraire → ensuite valider » n'est pas une rigueur académique, c'est une véritable protection contre des vulnérabilités grossières.
GO TO FULL VERSION