Accueil / Articles

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.

Éditeur : REACTAPP.TOP13 min de lecture2888 mots

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 :

  1. Lire l’en-tête Authorization.
  • Extraire le jeton à partir de la valeur Bearer.
  • Vérifier la signature et la date d’expiration.
  • Déterminer quel utilisateur représente le jeton.
  • Attacher cette identité à l’objet de demande pour les traitements ultérieurs.
  • Réfuser la demande avec une erreur si l’un des étapes échoue.
  • 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.
  • Retards qui augmentent progressivement après chaque échec.
  • Vérification de la présence de schémas suspects.
  • Protection basée sur l’adresse IP.
  • Avertissements pour les événements liés à la sécurité.
  • 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.
  • Les tentatives d’authentification échouées sont limitées sans permettre un abus du verrouillage.
  • Les secrets ne figurent pas dans le code et sont gérés de manière sécurisée.
  • Les journaux ne contiennent pas de mots de passe, de tokens ni d’autres valeurs sensibles.
  • Les réponses aux erreurs ne divulguent aucun détail interne inutile.
  • Les flux d’authentification font l’objet de tests automatisés.
  • L’invalidation des tokens et des sessions est conçue dès le départ, sans être reportée.
  • La configuration en production a été examinée.
  • 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.

    Lectures complémentaires

  • Lorsque l’authentification JWT devient étatique : un cas en faveur des sessions côté serveur dans Node — Découvrez comment les listes de blocage des révocations et les mécanismes de renouvellement rendent l’authentification JWT fragile, comment les sessions Express basées sur Postgres la simplifient, et dans quels cas les JWT restent pertinents.
  • Sécuriser les APIs Express : authentification, validation, limites de fréquence et surveillance — Une présentation pratique de la sécurité des APIs Express : authentification via Passport et JWT, modèles d’autorisation, chiffrement AES-GCM, validation, limites de fréquence et journalisation.