1. Différence entre 401 et 403 dans les API REST
Lorsque vous commencez à créer une « vraie » API JSON, vous comprenez assez vite : le code de statut n’est pas une « formalité pour la case à cocher », mais une partie du contrat. L’application cliente, le client mobile, la collection Postman, et même vos futurs tests automatisés lisent non seulement le corps de la réponse. Ils lisent d’abord le statut et en tirent des conclusions : il faut se connecter, il faut afficher un écran « accès refusé », il faut répéter la requête ou, au contraire, cesser immédiatement de spammer le serveur.
Le problème commence lorsque l’API répond « n’importe comment » : tantôt un 403 au lieu d’un 401, tantôt l’inverse. Pour un développeur humain, c’est désagréable mais supportable : il ouvrira les logs, vérifiera la configuration et devinera. Pour le client, cela se traduit par un comportement erroné. Par exemple, l’application pense que l’utilisateur « n’a pas les droits » alors qu’en réalité il ne s’est tout simplement pas connecté. Ou bien le client se met à se « reconnecter » sans fin, alors que la connexion est réussie : c’est juste que le rôle n’est pas le bon.
Pour fixer le sens, on peut garder en tête un petit tableau (ce n’est pas une « vérité absolue », mais une aide-mémoire pratique pour la conception) :
| Situation sur le serveur | Signification correcte | Code typique |
|---|---|---|
| L’utilisateur n’est pas authentifié (nous n’avons pas d’identité confirmée pour la requête) | « Identifiez-vous d’abord » | 401 |
| L’utilisateur est authentifié, mais la règle d’accès n’est pas respectée (rôle/authority/autre vérification échoue) | « Nous vous reconnaissons, mais vous n’y avez pas accès » | 403 |
Important : ces codes ne concernent pas « la gravité de votre faute ». Ce n’est pas « 401 — amende légère, 403 — bannissement à vie ». Ce sont des types d’erreurs différents, et ils répondent à des questions différentes.
2. Modèle mental : identification et accès
On a très envie de retenir 401 et 403 comme deux nombres magiques, mais les nombres sans signification se retiennent mal. C’est pourquoi un bon modèle mental commence toujours par une histoire humaine. Imaginez que notre API est un bureau avec un agent de sécurité. À l’entrée, on vous pose deux questions différentes.
D’abord, l’agent demande « qui êtes-vous ». Si vous ne montrez pas de badge, si vous montrez un badge qui ne vous appartient pas, ou si vous montrez quelque chose qui ne ressemble pas à un badge (par exemple une carte d’étudiant de 2007 — c’est aussi en plastique, mais ce n’est pas ça), la conversation s’arrête rapidement. C’est le 401 : le système n’a aucune raison de considérer que la requête émane d’un utilisateur confirmé.
En revanche, si vous avez montré votre badge, l’agent vous a reconnu, mais vous essayez d’entrer dans le local serveur ou dans le bureau du directeur, alors c’est une autre histoire. La question « qui êtes-vous » est résolue. Mais surgit la question « qu’avez-vous le droit de faire ». Et la réponse peut être « non ». C’est le 403 : l’identité est claire, mais les droits sont insuffisants.
Voici comment on peut représenter cela sous forme de schéma simple de traitement d’une requête (simplifié, mais utile pour le cerveau) :
flowchart TD
A[Requête HTTP] --> B{"Authentification réussie ?"}
B -- non --> U[Réponse : 401]
B -- oui --> C{"Règle d'accès remplie ?"}
C -- non --> F[Réponse : 403]
C -- oui --> D[Contrôleur / logique métier]
Si vous gardez cette bifurcation en tête, vous cesserez presque de confondre. Toute dispute « c’est 401 ou 403 » se résume à une seule vérification : « avons-nous un utilisateur confirmé dans le contexte de la requête ou pas ? »
3. 401 Unauthorized : quand vous n’avez pas d’utilisateur
401 est la situation où vous accédez à une ressource protégée, mais pour la requête actuelle il n’a pas été possible d’obtenir une authentification réussie. Il est important de noter que « n’a pas été possible » ne signifie pas seulement « n’a rien envoyé ». Cela inclut aussi « a envoyé, mais incorrect », « a envoyé un élément expiré », « a envoyé un identifiant/mot de passe, mais ils ne correspondent pas », « le compte est verrouillé et ne passe pas le flux d’authentification ».
Oui, le nom Unauthorized prête vraiment à confusion. En anglais, c’est un terme historiquement ancré, mais dans notre cours nous allons penser plus simplement : 401 signifie presque toujours Unauthenticated. C’est-à-dire « le système ne vous considère pas connecté ».
Pour le voir sur le projet réel Secure Content Platform API, prenons l’endpoint privé /api/me. Selon notre modèle d’accès, il ne doit fonctionner que pour un utilisateur authentifié. Par conséquent, sans connexion, il doit « buter » sur un 401.
Le contrôleur, en théorie, peut ressembler à ceci (nous le montrons non pour sa logique mais pour clarifier : la requête n’atteindra la méthode que si la sécurité la laisse passer) :
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
class MeController {
// Endpoint privé : seules les authentifications réussies doivent y accéder
@GetMapping("/api/me")
public Map<String, String> me() {
// Si vous voyez ce message, cela signifie que la couche de sécurité a laissé passer la requête
return Map.of("message", "private zone");
}
}
Observons maintenant une requête sans authentification (le moyen le plus honnête est curl -i pour voir le statut) :
# -i permet de voir le code de statut et les en-têtes de réponse
curl -i http://localhost:8080/api/me
# HTTP/1.1 401
Si vous utilisez HTTP Basic (nous l’avons déjà vu et il est pratique pour un client API), des identifiants incorrects donnent aussi un 401 car l’authentification a échoué :
# Identifiants incorrects => authentification échouée => 401
curl -i -u anna:wrong http://localhost:8080/api/me
# HTTP/1.1 401
Notez le point subtil : si dans votre projet il existe un utilisateur anna mais que le mot de passe est erroné, c’est tout de même « nous n’avons pas d’authentification réussie ». Autrement dit, ce n’est pas « 403 parce qu’Anna existe ». Non. Pour la requête actuelle, l’utilisateur n’a pas prouvé qu’il est Anna.
Et encore un point important des jours précédents : si le compte est locked ou disabled, la connexion n’est pas non plus considérée comme réussie. Du point de vue de la requête, c’est à nouveau 401. En interne, Spring Security peut avoir différentes causes (et différentes exceptions), mais pour le client externe, cela se résume généralement à une seule chose : « l’authentification a échoué ».
4. 403 Forbidden : quand l’utilisateur existe, mais n’a pas le droit
403 est un autre type d’histoire. C’est « nous t’avons reconnu, mais la règle d’accès n’est pas respectée ». Dans les termes de Spring Security, cela signifie que dans le SecurityContext il y a une Authentication qui est considérée comme réussie (ce n’est donc pas un anonymous et ce n’est pas « mot de passe erroné »), mais à l’étape d’autorisation quelqu’un a dit « non ».
Le plus souvent, dans un projet d’apprentissage, la cause d’un 403 est très claire : l’utilisateur n’a pas le bon rôle ou la bonne authority. Par exemple, un simple USER essaie d’accéder à une zone admin. Mais il est utile de se rappeler : un 403 peut arriver non seulement à cause du rôle. Dans une branche basée sur session, par exemple, l’absence d’un jeton CSRF sur une requête modifiant l’état se manifeste aussi souvent par un 403. Nous n’approfondissons pas CSRF aujourd’hui, mais en tant que fait pour le débogage, il est bon de s’en souvenir pour ne pas penser « 403 = forcément un rôle ».
Pour notre projet, l’exemple idéal est l’endpoint /api/admin/users. Il ne doit être accessible qu’au rôle ADMIN. Donc, un utilisateur normal, après une connexion réussie, doit recevoir un 403.
Contrôleur pour l’exemple :
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
class AdminController {
// Admin-endpoint : seuls les utilisateurs avec le rôle ADMIN doivent accéder
@GetMapping("/api/admin/users")
public Map<String, String> users() {
// Si vous voyez cette réponse, cela signifie que la vérification des rôles a été effectuée
return Map.of("message", "admin zone");
}
}
Et maintenant la requête. Supposons que anna s’authentifie avec succès, mais qu’elle a le rôle USER. Alors :
# Identifiants valides => authentification réussie, mais droits insuffisants => 403
curl -i -u anna:pass http://localhost:8080/api/admin/users
# HTTP/1.1 403
C’est bien 403, car la question « qui es-tu ? » est déjà résolue. Nous savons que la requête est faite par anna, simplement elle n’est pas admin.
En revanche, si on n’envoie aucune authentification, le même endpoint renverra 401, car la question « qui es-tu ? » n’a même pas été correctement entamée :
# Pas d'authentification => 401 (et non 403)
curl -i http://localhost:8080/api/admin/users
# HTTP/1.1 401
C’est le schéma clé : une même URL peut renvoyer 401 ou 403 selon que le client est authentifié ou non.
5. Un même endpoint : trois issues
Il est particulièrement difficile pour un débutant de s’habituer à l’idée que le statut ne dépend pas seulement de l’URL, mais aussi de l’état du contexte de sécurité. C’est pourquoi il est très utile de voir une fois « les trois destins d’un même endpoint ». Prenons /api/admin/users et observons ce qui se passe dans trois états : anonymous, utilisateur normal, admin.
Sous forme de tableau, cela donne :
| Qui fait la requête | Ce que le serveur a | Résultat attendu |
|---|---|---|
| Anonymous (n’a rien envoyé) | Pas d’authentification réussie | 401 |
| USER (connecté) | Utilisateur authentifié, mais pas de ROLE_ADMIN | 403 |
| ADMIN (connecté) | Utilisateur authentifié + rôle requis présent | 200 |
Et à la main, avec curl, cela se vit littéralement en une minute :
# 1) Anonymous => pas d'authentification => 401
curl -i http://localhost:8080/api/admin/users
# HTTP/1.1 401
# 2) USER => authentification réussie, mais rôle insuffisant => 403
curl -i -u anna:pass http://localhost:8080/api/admin/users
# HTTP/1.1 403
# 3) ADMIN => tout ok => 200
curl -i -u root:pass http://localhost:8080/api/admin/users
# HTTP/1.1 200
(Et oui, si vous vous dites « je peux toujours répondre 403 pour ne pas avoir à réfléchir » — vous le pouvez, mais alors le client pensera à votre place. Et il pensera de travers.)
Cet exemple renforce encore le sens : 401 signifie « pas connecté », 403 signifie « connecté, mais pas le droit ».
6. Comment Spring Security prend la décision : deux « barrières »
Si vous percevez Spring Security comme « un seul grand gardien », vous vous embrouillez plus vite. Il est plus utile de voir le système comme deux barrières séquentielles qui agissent dans les filtres, avant l’entrée dans le contrôleur. La première barrière s’occupe d’obtenir une authentification réussie et de la placer dans le SecurityContext. La deuxième barrière vérifie les règles d’accès à la requête spécifique et décide si elle peut passer.
En pratique, cela se lit très bien directement dans le SecurityFilterChain. Regardez une configuration typique pour nos zones :
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
// Règle n°1 : tout sous /api/admin/** nécessite le rôle ADMIN (c'est la barrière d'autorisation)
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Règle n°2 : toute autre requête nécessite au moins une authentification (c'est la barrière d'authentification)
.anyRequest().authenticated()
)
// Ensuite Spring Security décide lui-même quoi renvoyer : 401 (pas d'authentification) ou 403 (droits insuffisants)
.build();
}
C’est ici que se cache toute la logique des codes :
Si la requête arrive sur « n’importe quoi sauf /api/admin/** », alors authenticated() est requis. S’il n’y a pas d’authentification, c’est la première barrière, et cela ne sert à rien de vérifier les droits plus loin. Le résultat logique est donc 401.
Si la requête arrive sur /api/admin/**, alors le rôle ADMIN est requis. Et là, une bifurcation apparaît : s’il n’y a pas d’authentification, encore une fois d’abord 401 (première barrière). Si l’authentification est présente mais que le rôle manque, c’est la deuxième barrière, et elle se termine par 403.
Une pensée utile pour le cerveau : les règles authenticated(), hasRole("ADMIN"), hasAuthority("ADMIN") ne sont pas « trois façons différentes de renvoyer un code ». Ce sont différentes conditions de passage. Et le code (401 ou 403) est choisi en fonction de l’étape à laquelle vous avez « trébuché » : à l’entrée (pas d’utilisateur) ou à l’accès (utilisateur présent mais non conforme).
7. Comment une erreur de choix 401/403 casse le client
On pourrait penser « ce n’est qu’un code qui diffère d’un chiffre ». Mais dans le monde client, ces chiffres sont des commandes. De nombreux clients construisent leur comportement ainsi : si 401, alors il faut afficher l’écran de connexion ou renouveler les identifiants. Si 403, alors la connexion ne servira plus à rien, il faut afficher un message « accès refusé » et ne pas essayer de « réparer » la requête en la répétant.
Si vous renvoyez 403 là où l’utilisateur n’est simplement pas connecté, le client peut produire une UX très étrange. Imaginez une application mobile : l’utilisateur installe l’appli, ouvre la section profil, et voit immédiatement « Accès interdit ». Il n’a même pas encore vu l’écran de connexion. La probabilité qu’il pense « oh, je dois appuyer sur le bouton de connexion ailleurs » est à peu près la même que la probabilité qu’un chat configure lui-même votre SecurityFilterChain (c’est-à-dire quasi nulle).
À l’inverse, si vous renvoyez 401 là où l’utilisateur est connecté mais n’a pas les droits, le client peut entrer dans une boucle infinie de « se ré-authentifier et réessayer ». Parfois cela se manifeste par un retour constant à l’écran de connexion après chaque requête vers la zone admin, même si la connexion est correcte. L’utilisateur saisit son mot de passe encore et encore, commence à détester votre application, et vous commencez à détester la vie.
C’est pourquoi une sémantique correcte de 401/403 n’est pas une question d’esthétique. C’est une véritable robustesse du comportement client-serveur.
8. Aide-mémoire pour le débogage : comment lire 401 et 403
Lorsque vous voyez un 401, il est utile de ne pas paniquer en criant « tout est cassé », mais de vous poser d’abord la question fondamentale : « est-ce que le serveur voit un utilisateur ? ». Et ensuite de penser de manière très concrète : y avait-il un en-tête Authorization dans la requête (si c’est du Basic), y avait-il un cookie de session (si c’est une branche basée sur session), le compte n’est-il pas verrouillé, ne nous sommes-nous pas trompés d’identifiant/mot de passe.
Quand vous voyez un 403, la question est différente : « l’utilisateur existe, mais quelle règle n’a-t-il pas respectée ? ». Et là commence la lecture de la matrice d’accès. Si la configuration est écrite proprement, vous trouvez assez rapidement le matcher correspondant. Si vous utilisez des rôles, vous comparez le rôle attendu (ADMIN) avec les rôles réels de l’utilisateur. Si vous avez déjà commencé à utiliser les authorities, vous vérifiez que la chaîne attendue est bien présente dans les GrantedAuthorities (et que vous n’avez pas confondu hasRole("ADMIN") avec hasAuthority("ADMIN"), car ce sont des mondes différents).
Au niveau de la réflexion, on peut garder cet ordre : d’abord prouver que l’authentification existe (sinon toute discussion sur les rôles est vide), et ensuite seulement analyser les droits. Cela fait gagner beaucoup de temps, car la plupart des « 403 » chez un débutant se révèlent en réalité être « je n’ai pas transmis le cookie/l’Authorization » et cela aurait dû être un 401, mais quelque part les sémantiques du navigateur ou le client attendaient un comportement différent.
De cette bifurcation dépend directement la mécanique même de la réponse : l’absence d’authentification doit aboutir à un point de sécurité, et l’interdiction pour un utilisateur déjà connu à un autre point. Lorsque ces deux branches sont correctement séparées, on peut alors les réduire à un seul contrat JSON.
9. Erreurs typiques dans la distinction entre 401 et 403
Quand vous débutez, vous avez très envie d’avoir une « explication universelle » du genre « 401 — c’est quand le mot de passe est faux, 403 — c’est quand le rôle est faux ». Et c’est presque vrai, mais c’est ce « presque » qui provoque des maux de tête. Les erreurs se répètent de projet en projet, il est donc préférable de les connaître maintenant, pendant notre projet d’apprentissage, plutôt que sur un environnement de production en feu.
Erreur n°1 : percevoir 401 comme « mauvais rôle ».
C’est la confusion la plus fréquente à cause du mot Unauthorized. En réalité, le rôle n’est vérifié qu’après que l’utilisateur a été authentifié avec succès. Si l’identifiant/mot de passe n’a pas fonctionné ou n’a pas été transmis du tout, ce n’est pas « mauvais rôle », c’est « pas d’utilisateur ». La vérification de base est donc : avant de penser aux rôles, assurez-vous que la requête a bien été authentifiée.
Erreur n°2 : renvoyer 403 pour une requête anonymous vers un endpoint privé.
Cela arrive souvent quand le développeur « à la main » renvoie des statuts depuis le contrôleur, ou quand les réglages sont faits de sorte que le flux du navigateur masque le vrai sens de l’erreur. Pour /api/me, un anonymous doit recevoir 401. S’il reçoit 403, le client commence à penser que « l’accès est interdit pour toujours » alors qu’il suffit simplement de se connecter.
Erreur n°3 : renvoyer 401 alors que l’utilisateur est déjà connecté mais n’a pas les droits.
Par exemple, un USER va sur /api/admin/users, reçoit 401, le client décide « le jeton/la session a expiré » et force l’utilisateur à se reconnecter. L’utilisateur se reconnecte honnêtement, et reçoit à nouveau 401. C’est très mauvais visuellement et transforme le système en un couloir sans fin avec une porte toujours fermée, mais où on vous dit à chaque fois « présentez à nouveau votre badge ».
Erreur n°4 : essayer de « réparer » 401/403 avec un if dans le contrôleur.
Le contrôleur est un point trop tardif. La distinction correcte entre 401 et 403 se fait dans la couche de sécurité, avant que Spring MVC ne sélectionne la méthode du contrôleur. Quand vous commencez à écrire « si l’utilisateur n’est pas le bon — renvoyer le statut » dans le contrôleur, vous dupliquez la logique de sécurité, la rendez non centralisée et fragile. Et en prime, vous risquez d’ouvrir accidentellement l’accès là où il ne faut pas, parce que vous avez oublié l’une des entrées.
Erreur n°5 : « un seul texte pour tout » : le même message pour 401 et 403.
Même si le format JSON est unique (ce qui est bien), le sens des messages doit différer. Pour 401, le message doit dire « authentification requise », pour 403 — « authentification réussie mais accès refusé ». Sinon, clients et développeurs devront deviner d’après le contexte ce qui s’est passé, et deviner n’est pas la compétence que nous voulons développer dans le développement backend.
GO TO FULL VERSION