Création d’une authentification JWT prête à l’emploi dans les API Node.js
Comment hacher les mots de passe, émettre des JWT à durée de vie courte, ajouter des tokens de renouvellement et un mécanisme de révocation, séparer l’autorisation de l’authentification, résister aux attaques par force brute et s’assurer que l’authentification échoue correctement.
Sources: JWT Authentication in Node.js: What I Learned Building Secure APIs
Dans un document de spécifications, l’authentification se résume en une seule ligne : un utilisateur se connecte avec son e-mail et son mot de passe, et l’API accorde l’accès. Dès que vous la mettez en œuvre, cette ligne se transforme en une douzaine de questions. Où sont stockés les mots de passe et comment sont-ils vérifiés ? Comment l’API reconnaît-elle l’utilisateur à chaque demande ? Que se passe-t-il lorsque un jeton expire, quel rôle jouent les jetons de renouvellement, et que doit faire le système après une série d’échecs de connexion ? Au fond de tout cela se trouve la question la plus importante : comment empêcher le système de connexion lui-même de devenir la partie la plus fragile de l’application ?
Ce guide aborde ces questions pour une API Node.js et TypeScript alimentée par PostgreSQL, en considérant l’authentification non pas comme un seul point d’accès, mais comme une couche de sécurité qui entoure l’ensemble de l’application. À la fin, vous disposerez d’un modèle clair de chaque couche, des pièges potentiels à chacune d’elles, ainsi que d’une liste de contrôle pour déterminer quand le système est réellement prêt pour la production.
Stockez des hachages de mots de passe, jamais les mots de passe eux-mêmes
La première règle est qu’un mot de passe n’est pas un simple ensemble de données. Il ne doit jamais être enregistré sous forme de texte brut. Un enregistrement d’utilisateur comme celui ci-dessous est inacceptable, car une simple fuite de données dans la base de données exposerait le mot de passe de chaque utilisateur, et comme les gens réutilisent souvent leurs mots de passe, cela exposerait également leurs comptes ailleurs.
email: sachin@example.com
password: MyPassword123
Plutôt que cela, faites passer chaque mot de passe par un algorithme de hachage délibérément lent avant de le sauvegarder. Avec le paquet bcrypt, vous importez la bibliothèque :
import bcrypt from "bcrypt";
et vous hachez le mot de passe lors de l’inscription. Le deuxième argument, 12, correspond au facteur de coût : chaque incrémentation double le travail requis, ce qui rend les tentatives de devinette hors ligne contre une table de hachage volée bien plus coûteuses, tout en restant suffisamment rapide pour un seul connexion.
const passwordHash = await bcrypt.hash(password, 12);
Lors de la connexion, rien n’est déchiffré. Le serveur prend le mot de passe que l’utilisateur vient de soumettre et demande à bcrypt s’il correspond au hachage stocké. bcrypt.compare lit le sel et le facteur de coût intégrés dans le hachage, répète le calcul et renvoie une valeur booléenne.
const isValid = await bcrypt.compare(
password,
user.passwordHash
);
La différence clé réside dans le fait que les mots de passe sont hachés, et non chiffrés, afin de pouvoir être récupérés ultérieurement. Une fois l’inscription terminée, votre application ne devrait jamais avoir besoin de récupérer le mot de passe original, ni en être capable. Deux remarques pratiques : bcrypt ne prend en compte que les 72 premiers octets de l’entrée, ce qui entraîne le troncature silencieuse des mots de passe très longs, et des algorithmes tels qu’Argon2 constituent une alternative courante si vous souhaitez une solution exigeant beaucoup de ressources mémoire.
Considérez la connexion comme un flux de travail, et non une requête
Un point d’entrée typique pour se connecter suit une série d’étapes, allant de la réception de la demande à la validation, à la recherche, à la comparaison et à la création d’un jeton :
Client
↓
POST /api/auth/login
↓
Validate input
↓
Find user
↓
Compare password
↓
Create authentication tokens
↓
Return authentication response
Ce qui est important, c’est que chaque étape peut échouer et que chaque échec nécessite une réponse délibérée. L’e-mail peut être mal formaté ; aucun utilisateur ne correspondra peut-être à ces informations. Le mot de passe peut être incorrect ; le compte peut être verrouillé. La base de données peut être inaccessible. Il s’agit d’un flux de travail sensible sur le plan de la sécurité, et chaque branche doit être conçue, enregistrée de manière appropriée et testée.
Qu’est-ce qu’un JWT et ce qu’il n’est pas
Les tokens JSON Web constituent un moyen courant de représenter une identité authentifiée dans une API. Après une connexion réussie, le serveur émet un token signé décrivant l’utilisateur. Une charge utile minimale peut ne contenir que le sujet et son rôle ; sub est la clé standard utilisée pour identifier l’utilisateur.
{
"sub": "user-id",
"role": "user"
}
La charge utile est codée, non chiffrée, ce qui permet à quiconque possède le jeton de la lire. Elle doit contenir uniquement des identifiants et des informations générales, jamais de secrets ou de données personnelles. Lors des demandes ultérieures, le client inclut le jeton dans l’en-tête Authorization en utilisant le schéma Bearer :
Authorization: Bearer <access-token>
Au préalable de servir toute ressource protégée, le backend vérifie la signature et la date d’expiration du jeton. La séquence globale est la suivante :
Login
↓
Credentials verified
↓
Access token issued
↓
Client sends token
↓
Server verifies token
↓
Protected API is allowed
La leçon essentielle est qu’un JWT ne rend pas une application sécurisée par lui-même. La sécurité dépend autant de la manière dont le jeton est créé, du lieu où le client le stocke, du degré de validation appliqué (y compris la fixation de l’algorithme de signature accepté), de sa date d’expiration, de la possibilité de le révoquer, ainsi que du mode de transmission sur le réseau (toujours via HTTPS).
Rendre les tokens d’accès à courte durée de vie
Les jetons d’accès ne devraient pas être valables indéfiniment. Si un jeton valide pendant des mois s’échappe via un fichier de journal ou un appareil compromis, un attaquant peut l’utiliser pendant des mois.
L’exemple ci-dessous utilise jsonwebtoken pour signer un jeton contenant l’ID et le rôle de l’utilisateur à l’aide d’un secret lu depuis l’environnement, et il définit expiresIn sur quinze minutes. Le ! après process.env.JWT_SECRET correspond à l’assertion non nulle de TypeScript ; dans du code en production, il est plus sûr de vérifier au démarrage que la variable existe plutôt que d’effectuer une assertion à chaque point d’appel.
const accessToken = jwt.sign(
{
sub: user.id,
role: user.role
},
process.env.JWT_SECRET!,
{
expiresIn: "15m"
}
);
La durée de validité idéale dépend de vos exigences en matière de sécurité, mais le principe reste le même partout : limitez la durée pendant laquelle tout identifiant permettant d’accéder à des ressources protégées reste utilisable. Une durée courte signifie que un jeton volé devient rapidement inutilisable.
Les jetons de renouvellement résolvent le problème d’utilisabilité
Si les tokens d’accès expiraient tous les quinze minutes, les utilisateurs seraient contraints de se connecter constamment, ce que personne ne tolérerait. Les tokens de renouvellement résolvent ce problème. Lors de la connexion, le client reçoit deux identifiants : un token d’accès à durée de vie courte utilisé pour les appels API, et un token de renouvellement à durée de vie plus longue dont la seule fonction est d’obtenir un nouveau token d’accès lorsque l’ancien expire.
┌─────────────────┐
│ Login │
└────────┬────────┘
↓
┌─────────────────┐
│ Access Token │
│ Short Lifetime │
└────────┬────────┘
│
API Requests
│
↓
Token expires
│
↓
┌─────────────────┐
│ Refresh Token │
│ Longer Lifetime │
└────────┬────────┘
↓
New Access Token
Cette approche permet aux tokens d’accès de rester courts tout en assurant une continuité perçue des sessions. Elle introduit également une nouvelle responsabilité, car le token de renouvellement devient désormais la clé d’accès la plus précieuse. Il doit être stocké avec soin (généralement dans un cookie httpOnly, Secure sur le web plutôt que dans un espace de stockage accessible depuis JavaScript), envoyé uniquement à l’endpoint de renouvellement, et validé strictement par le serveur. Pour une discussion plus approfondie sur la conception, consultez notre guide sur la stratégie de token de renouvellement pour les systèmes d’authentification Node.js.
Plan de révocation
Une caractéristique des jetons autonomes qui surprend de nombreuses équipes est le fait qu’ils restent valides même après des changements sur le serveur. Prenons l’exemple d’un utilisateur qui se déconnecte, d’un administrateur qui désactive un compte, d’un jeton de renouvellement connu pour avoir été compromis, ou encore d’un incident de sécurité qui exige la fin de toutes les sessions. Si la conception ne prévoit aucun mécanisme de révocation, supprimer ou mettre à jour une ligne de base de données n’a aucun effet sur un jeton déjà émis ; il continuera à fonctionner jusqu’à son expiration.
Pour cette raison, de nombreux systèmes d’authentification conservent des enregistrements côté serveur des jetons de renouvellement ou des sessions, souvent un par appareil, associés à chaque utilisateur :
User
├── Refresh Token A
├── Refresh Token B
└── Refresh Token C
Avec cet enregistrement en place, le serveur peut marquer des jetons individuels comme révoqués, mettre fin à toutes les sessions d’un utilisateur et refuser de générer de nouveaux jetons d’accès à partir d’un jeton de renouvellement révoqué. Cela révèle un compromis équitable : les JWT réduisent la quantité d’état de session que le serveur doit conserver, mais une authentification sécurisée nécessite généralement encore un certain état côté serveur. Des durées de vie courtes des jetons d’accès associées à des jetons de renouvellement révocables constituent une méthode courante pour équilibrer ces deux aspects.
Imposer l’authentification dans le middleware
Faire des vérifications de jeton à chaque contrôleur est sujette aux erreurs ; une vérification oubliée laisse un point d’accès vulnérable. Le middleware centralise cette logique. Dans Express, une route protégée consiste simplement à placer une fonction authenticate avant son contrôleur :
app.get(
"/api/profile",
authenticate,
profileController
);
Cet middleware a une tâche petite et bien définie :
- Lire l’en-tête
Authorization.
Le flux de contrôle résultant est facile à comprendre : un jeton invalide ou manquant génère une réponse 401, et seul un jeton valide parvient au contrôleur.
Request
↓
Authentication Middleware
↓
Token valid?
↙ ↘
No Yes
↓ ↓
401 Controller
↓
Response
L’authentification et l’autorisation sont des vérifications distinctes
Ces deux termes sont souvent utilisés de manière interchangeable, mais ils répondent à des questions différentes. L’authentification demande qui est l’utilisateur. L’autorisation demande ce que cet utilisateur a le droit de faire. Notre article sur où se situent l’authentification et l’autorisation dans votre code explore plus en détail cette distinction.
Imaginons une application avec trois rôles :
Admin
Staff
User
Les membres des trois rôles peuvent tous être pleinement authentifiés, mais leurs permissions diffèrent fortement. Un utilisateur ordinaire peut avoir le droit de lire son profil, de lister les problèmes et d’en créer de nouveaux :
GET /api/profile
GET /api/issues
POST /api/issues
Un administrateur peut en outre avoir le droit de modifier les comptes d’utilisateurs et de supprimer des problèmes :
PATCH /api/users/:id
DELETE /api/issues/:id
Disposer d’un JWT valide ne prouve que l’identité ; il n’accorde pas de droits pour chaque opération. Un backend sécurisé a besoin des deux niveaux de protection : généralement un middleware d’authentification suivi de vérifications de rôle ou de droits par route, et idéalement aussi des vérifications d’appartenance afin qu’un utilisateur puisse modifier son propre problème mais pas celui d’autrui.
Protéger contre les tentatives de connexion échouées répétées
La conception de l’authentification doit tenir compte des attaquants, et pas seulement des utilisateurs légitimes. Un script automatisé peut soumettre des tentatives de mot de passe indéfiniment à moins que quelque chose ne l’arrête. La défense la plus simple est un compteur qui déclenche un verrouillage temporaire après plusieurs échecs consécutifs :
Attempt 1 → Failed
Attempt 2 → Failed
Attempt 3 → Failed
Attempt 4 → Failed
Attempt 5 → Failed
↓
Temporary lock
En pratique, vous combinerez plusieurs mesures en fonction de vos besoins :
- Limitation du nombre de requêtes vers l’endpoint de connexion.
- Verrouillage temporaire des comptes.
Choisissez avec soin, car une mesure de défense peut devenir un vecteur de refus de service. Si n’importe qui peut verrouiller un compte en saisissant incorrectement son e-mail cinq fois, les attaquants peuvent délibérément bloquer des utilisateurs légitimes. L’objectif n’est pas seulement de bloquer après des tentatives infructueuses, mais aussi de rendre les attaques automatisées coûteuses tout en permettant aux utilisateurs légitimes d’accéder, par exemple en combinant des retards par compte avec des limites de vitesse par IP plutôt que des blocages définitifs.
N’ayez jamais confiance dans l’identité fournie par le client
Un schéma dangereux consiste à croire aux données simplement parce que l’interface utilisateur les a envoyées. Supposons qu’un corps de demande indique l’ID d’utilisateur et le rôle de l’appelant :
{
"userId": "123",
"role": "admin"
}
Rien n’empêche un client malveillant de modifier ces champs. Le backend ne doit jamais supposer qu’ils sont fiables. L’identité doit provenir de identifiants que le serveur a vérifiés lui-même, ce qui signifie que l’ID utilisateur et son rôle sont lus dans le jeton validé (ou dans la base de données en utilisant l’identifiant du sujet du jeton), et que les vérifications de permissions se basent sur cela :
Request
↓
Verify JWT
↓
Read authenticated user ID
↓
Check permissions
↓
Access resource
Le serveur est l’autorité finale. Ce principe s’étend bien au-delà de l’authentification : le client peut améliorer l’expérience en cachant des boutons que l’utilisateur ne peut pas utiliser, mais on ne peut jamais compter sur lui pour appliquer des règles de sécurité.
Éviter la fuite d’informations dans les messages d’erreur
La sécurité concerne aussi des détails tels que la formulation des erreurs. Comparez une réponse indiquant que l’adresse e-mail n’existe pas :
Email does not exist.
à une autre qui reste délibérément vague :
Invalid email or password.
Le premier élément indique à un attaquant quels e-mails sont enregistrés, ce qui permet l’énumération des comptes et facilite la tentative de devinette de mots de passe ciblés. Le second donne à un utilisateur légitime suffisamment d’informations pour réessayer sans avoir à confirmer quoi que ce soit. Il convient de se rappeler que l’énumération peut également avoir lieu grâce au temps de réponse ou via des points d’entrée de registration et de réinitialisation de mot de passe, donc les mêmes précautions s’appliquent également dans ces cas.
La règle plus générale couvre également les erreurs de base de données et les exceptions non gérées. Les clients ont besoin d’une erreur sur laquelle ils peuvent agir ; ils n’ont pas besoin de traces d’exécution, de messages SQL ou d’identifiants internes.
Éloignez les secrets du code source
L’authentification dépend de secrets tels qu’une clé de signature, une chaîne de connexion à la base de données et des clés API de tiers :
JWT_SECRET
DATABASE_URL
API_KEY
Ces éléments ne doivent en aucun cas être commités dans un répertoire de code. Une clé codée en dur comme celle-ci représente un risque dès que quiconque peut lire le code :
const secret = "my-super-secret-key";
La lecture de cette clé depuis l’environnement constitue au minimum une amélioration :
const secret = process.env.JWT_SECRET;
En environnement de production, il convient d’aller plus loin en fournissant les secrets via un système de configuration approprié ou un système de gestion des secrets. Cela est particulièrement important pour les répertoires publics, où un commit accidentel d’une clé peut dégénérer en incident grave presque immédiatement ; dans ce cas, il faut renouveler le secret plutôt que de simplement supprimer le commit.
Vérifier que l’authentification échoue correctement
Les tests manuels ne conviennent pas bien pour l’authentification, car les cas importants sont ceux où elle ne doit pas réussir. Un ensemble de tests automatisés de base devrait couvrir à la fois les cas de succès et ceux d’échec pour chaque étape du processus.
Inscription
Les inscriptions valides aboutissent avec succès, tandis que les adresses e-mail mal formatées, les données invalides et les comptes dupliqués sont rejetés :
Valid registration → Success
Invalid email → Rejected
Weak/invalid input → Rejected
Duplicate account → Rejected
Connexion
Les identifiants corrects permettent de se connecter ; les mots de passe erronés, les comptes inconnus et les comptes verrouillés sont refusés :
Correct password → Success
Wrong password → Rejected
Unknown account → Rejected
Locked account → Rejected
Routes protégées
Les jetons manquants, invalides ou expirés génèrent tous un code 401 ; un jeton valide permet l’accès, tandis qu’un jeton valide sans le rôle requis produit un code 403. Notez la différence : 401 signifie « nous ne savons pas qui vous êtes », tandis que 403 signifie « nous savons qui vous êtes, et la réponse est non ».
No token → 401
Invalid token → 401
Expired token → 401
Valid token → Allowed
Valid token + insufficient role → 403
Des tests de ce type vont au-delà de la simple confirmation que l’authentification fonctionne. Ils prouvent qu’elle échoue de la bonne manière, c’est-à-dire là où se cachent la plupart des vulnérabilités réelles.
Un modèle en couches pour l’authentification
Il est utile d’imaginer l’authentification comme une pile de couches distinctes, chacune ayant une responsabilité précise, allant des identifiants jusqu’à la vérification du mot de passe, l’émission de jetons, leur validation et l’autorisation, en passant par la ressource protégée elle-même :
┌─────────────────────────────┐
│ User Credentials │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Password Verification │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Token Issuance │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Token Validation │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Authorization │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Protected Resource │
└─────────────────────────────┘
Autour de toute cette pile se trouve une couche transversale : le suivi, l’enregistrement des événements, la limitation du nombre de requêtes, la configuration sécurisée et les tests.
Liste de contrôle pour une mise en production
Au moment de considérer un système d’authentification comme complet, assurez-vous que :
- Les mots de passe sont hachés à l’aide d’un algorithme robuste et lent.
- Les jetons d’accès ont une durée de validité courte et adaptée.
- Les jetons de renouvellement sont stockés, transmis et validés de manière sécurisée.
- Chaque route protégée valide l’authentification.
- L’autorisation est appliquée en tant que étape distincte de l’authentification.
Cette liste constitue un critère bien plus fiable que de se demander simplement si l’authentification fonctionne.
Points clés
L’authentification n’est pas quelque chose à ajouter en fin de projet. Elle influence la conception de la base de données, la structure de l’API, le middleware, le comportement du frontend, la gestion des erreurs, le journalisation, les tests et le déploiement, et surtout la sécurité. Un point d’accès pour se connecter ne nécessite que quelques lignes de code ; en revanche, créer un système qui fonctionne de manière sécurisée face à des utilisateurs réels, des attaquants, des tokens expirés, des requêtes mal formatées et des pannes en production représente un problème d’ingénierie bien plus complexe.
La prochaine étape naturelle à explorer est la sécurité des tokens de renouvellement en profondeur : où stocker ces tokens, s’ils doivent être renouvelés et comment, comment détecter la réutilisation d’un token ancien, à quoi ressemble en pratique la révocation d’un token, et comment fonctionnerait le déconnexion sur plusieurs appareils. Chacune de ces décisions suit le même principe que tout ce qui a été mentionné précédemment : la sécurité est assurée lorsque elle fait partie de chaque choix de conception, et non lorsqu’elle est ajoutée en tant que couche supplémentaire.