Accueil / Articles

Sécurité des API : JWT et OAuth, validation, limitation des débits, surveillance.

Mettre en pratique la sécurité des API : authentification et autorisation JWT et OAuth, chiffrement, validation des entrées, limitation des débits et journalisation des attaques.

Éditeur : REACTAPP.TOP22 min de lecture5067 mots

Sources: What Are the Best Practices for API Security? A Complete Guide to Securing Modern APIs

Chaque API que vous mettez en ligne représente une porte d’accès à votre système, et les attaquants les testent de manière bien plus systématique que la plupart des équipes ne les vérifient. Une sécurité ajoutée a posteriori après le lancement a tendance à laisser des failles : une route sans protection, une requête construite à partir de données brutes, un point d’accès de connexion acceptant volontiers un million de tentatives. Ce guide présente les normes à connaître, les menaces les plus fréquentes, ainsi que six couches de défense concrètes à mettre en œuvre avec Node.js et Express, afin que vous puissiez auditer une API existante ou en créer une nouvelle dotée de protections dès le premier jour.

Considérez ce qui suit comme une liste de contrôle à laquelle vous devrez vous référer tout au long du cycle de développement : avant une publication, après un correctif, et chaque fois qu’une dépendance ou une route change. Effectuer régulièrement ces vérifications permet de détecter les vulnérabilités tant qu’elles sont encore mineures.

Pourquoi la sécurité des API mérite une attention particulière

Les produits modernes sont de plus en plus assemblés à partir d’API. Au lieu de développer chaque fonctionnalité en interne, les équipes intègrent des services de paiement, d’identification, de messagerie et de données via des interfaces bien définies, et de nombreuses entreprises proposent désormais des produits axés sur les API. Une étude de marché évalue l’économie des API à environ 20 milliards de dollars d’ici 2026 (résumé du rapport) ; quel que soit le chiffre exact, cette dépendance est réelle et en augmentation.

Cette dépendance a des effets des deux côtés. Une API offre aux clients légitimes des fonctionnalités prêtes à l’emploi et réutilisables, mais elle fournit également aux attaquants un point d’accès documenté et adapté aux machines. Les enquêtes sectorielles mettent constamment en relation une grande partie des fuites de données avec les API ; le rapport de Traceable de 2023 attribue 74 % des fuites de données à celles-ci.

Les API se trouvent fréquemment directement en face des données les plus sensibles détenues par une entreprise : des plateformes d’identité contenant des informations personnelles, des documents financiers, des workflows internes. Un accès non autorisé peut entraîner des données corrompues, un mauvais usage des services, des pertes financières, une perte de confiance de la part des clients, ainsi que des sanctions réglementaires en vertu de la loi sur la protection des données. Étant donné l’importance de ces enjeux, la sécurité doit faire partie des accords de niveau de service aux côtés du temps de disponibilité, et c’est la responsabilité de chaque équipe de développement, et non seulement d’un groupe dédié à la sécurité. Un bon point de départ est de s’appuyer sur les normes déjà convenues par l’industrie.

Normes à connaître, de OWASP à OAuth et JWT

Les normes de sécurité des API sont des spécifications formelles, des protocoles et des directives appliqués tout au long du cycle de vie du développement logiciel afin que la protection soit cohérente plutôt que improvisée. Ce sont celles que vous rencontrerez le plus souvent :

  • OWASP API Security Top 10 : une liste classée des risques les plus critiques pour les API, tenue à jour par le Open Web Application Security Project. Elle couvre des problèmes tels que l’autorisation au niveau de l’objet défectueuse, la falsification de requêtes côté serveur et une consommation de ressources non limitée, et constitue le meilleur point de départ pour une analyse des menaces.
  • OAuth 2.0 et 2.1 : un cadre d’autorisation déléguée. Un client obtient un jeton d’accès doté de scopes définis et l’utilise pour agir au nom d’un utilisateur sans jamais avoir accès à son mot de passe ; les jetons de renouvellement permettent au client d’obtenir de nouveaux jetons d’accès sans devoir redemander l’autorisation à l’utilisateur.
  • OpenID Connect (OIDC) : une couche d’identité par-dessus OAuth. Elle standardise le jeton d’identité ainsi que la manière dont les clients le valident, ce qui rend possible le connexion unique et la récupération des profils auprès des fournisseurs d’identité de manière interopérable.
  • JSON Web Tokens (JWTs) : un format de token compact et signé. Contrairement à un cookie de session opaque, un JWT contient ses informations (ID utilisateur, rôle, date d’expiration) directement à l’intérieur du token lui-même, et le serveur vérifie la signature plutôt que de rechercher une session. Il convient de noter qu’un JWT signé standard est codé, et non chiffré, ce qui permet à quiconque le détient de lire son contenu.
  • Transport Layer Security (TLS) : le protocole cryptographique qui sous-tend HTTPS. Il assure le chiffrement des données en transit, l’authentification des parties communicantes ainsi que des vérifications d’intégrité permettant de détecter toute modification non autorisée.
  • Zero Trust : une approche architecturale qui considère chaque utilisateur et service comme non fiable tant qu’ils n’ont pas été vérifiés. Elle repose sur une vérification explicite, le principe du moindre privilège et l’hypothèse qu’une violation a déjà eu lieu quelque part.
  • Framework de cybersécurité NIST : des directives particulièrement adaptées aux architectures cloud et de microservices, mettant l’accent sur l’authentification, l’autorisation et la protection des données.
  • Profils API de niveau financier (FAPI) : des profils OAuth et OIDC renforcés pour des domaines à haut risque tels que la banque, la fintech et les données réglementées. Ils exigent une authentification client solide, un traitement plus strict des requêtes et des réponses, ainsi que des tokens soumis à des contraintes sur leur émetteur, devenant inutilisables en cas de vol, ce qui permet à la fois de prévenir la fraude et d’améliorer l’interopérabilité.

Ces éléments vous fournissent une base solide, bien qu’il ne s’agisse pas d’une liste exhaustive.

Les menaces auxquelles vous vous protégez

Les vulnérabilités les plus fréquentes dans les APIs réelles sont :

  1. Authentification défaillante : des vérifications d’identité faibles ou absentes ainsi qu’une gestion insuffisante des sessions permettent à un attaquant de voler des cookies ou des tokens et de les réutiliser pour accéder à vos services.
  2. Autorisation au niveau de l’objet défaillante (BOLA) : l’API vérifie que l’utilisateur est connecté, mais pas qu’il a le droit d’accéder à un enregistrement spécifique ; ainsi, modifier un ID dans l’URL expose les données ou les processus internes d’une autre personne.
  3. Injection SQL : des données contrôlées par l’attaquant sont concaténées à une requête et exécutées par la base de données. Presque tous les clients de base de données offrent un mécanisme de paramètres permettant de transmettre des valeurs en toute sécurité.
  4. Injection de commandes : des données non fiables provenant d’une requête parviennent à une shell système ou à un processeur de commandes, permettant à l’attaquant d’exécuter des commandes arbitraires en utilisant les privilèges du processus serveur.
  • Scripting intersite (XSS) : un script injecté s’exécute dans le navigateur de la victime dans le contexte de votre application, permettant à l’attaquant d’agir en tant que cet utilisateur et de lire des données que la politique de même origine protégerait normalement.
  • Misconfiguration de la sécurité : clés API divulguées, variables d’environnement exposées, paramètres par défaut trop permissifs. Des identifiants compromis peuvent être utilisés pour appeler votre API ou pour générer des factures liées à des services tiers payants.
  • Exposition excessive ou sensible de données : un gestionnaire renvoie l’ensemble de l’objet de base de données au lieu des champs nécessaires au client. Même si l’interface utilisateur ne les affiche jamais, ces données restent dans les caches, le stockage local et l’onglet réseau du navigateur.
  • Refus de service (y compris les attaques distribuées) : une vague d’requêtes épuise les ressources du serveur ou met l’API complètement hors ligne.
  • Dépendances de tiers : votre API est également une cliente d’autres APIs et de paquets logiciels. Chacun d’eux élargit votre surface d’attaque, et toute violation de sécurité ou panne survenant chez eux devient alors un problème pour vous.
  • Chacune de ces situations correspond à une pratique de programmation spécifique. Le reste de ce guide les aborde en six niveaux, en utilisant JavaScript et Express tout au long.

    1. Authentification par mots de passe et JWT : prouver l’identité de l’utilisateur

    Toute API protégée doit exiger que le client prouve son identité avant de pouvoir effectuer des actions significatives, que ce soit par le biais d’un nom d’utilisateur et d’un mot de passe, d’une clé API ou d’un jeton signé. Les méthodes d’authentification se répartissent généralement en cinq catégories : authentification par nom d’utilisateur et mot de passe, authentification à plusieurs facteurs, authentification basée sur des jetons, authentification basée sur des certificats et biométrie.

    Une source fréquente de confusion concerne ce que remplace réellement un JWT. Les sessions serveur traditionnelles stockaient l’état sur le serveur et dépendaient des cookies du navigateur pour transmettre un identifiant de session. Un JWT élimine la nécessité de cette recherche côté serveur à chaque requête, mais il ne vérifie pas les identifiants par lui-même : quelqu’un doit encore vérifier le mot de passe une fois avant que le jeton ne soit émis. C’est pourquoi un flux hybride fonctionne bien. L’utilisateur se connecte avec son e-mail et son mot de passe, le serveur émet un JWT en cas de succès, et chaque requête ultérieure ne présente que le jeton. La vérification des identifiants et l’authentification par requête deviennent alors des tâches distinctes, et le mot de passe n’est plus transmis avec chaque appel.

    Au sein d’Express, la bibliothèque Passport prend en charge ces deux aspects grâce à des stratégies interchangeables. La configuration se fait en trois étapes.

    Étape 1 : enregistrer une stratégie locale et une stratégie JWT

    La stratégie locale s’exécute une seule fois lors de la connexion et est chargée de rechercher l’utilisateur par e-mail ainsi que de comparer le mot de passe soumis avec le hash enregistré. La stratégie JWT s’exécute à chaque requête protégée : elle extrait le jeton de l’en-tête Authorization: Bearer, vérifie la signature par rapport à JWT_SECRET, et identifie l’utilisateur mentionné dans la charge utile. L’exportation d’un middleware authenticateJWT prédéfini avec session: false rend explicite l’intention sans état.

    const passport = require("passport");
    const LocalStrategy = require("passport-local").Strategy;
    const { Strategy: JwtStrategy, ExtractJwt } = require("passport-jwt");
    
    // Local Strategy: Verify username and password during login.
    passport.use(
      new LocalStrategy(
        { usernameField: "email", passwordField: "password" },
        async (email, password, done) => {
          // Find the user and compare the hashed password.
          // If valid, return the user.
        }
      )
    );
    
    // JWT Strategy: Verify the token on protected requests.
    passport.use(
      new JwtStrategy(
        {
          jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
          secretOrKey: process.env.JWT_SECRET,
        },
        async (payload, done) => {
          // Find the user referenced in the token.
        }
      )
    );
    
    // Middleware
    const authenticateJWT = passport.authenticate("jwt", { session: false, });
    
    module.exports = { passport, authenticateJWT, };
    

    Les appels de vérification sont laissés ici en tant que commentaires, et c’est là que réside le véritable travail de sécurité. Utilisez un algorithme de hachage lent et avec sel, comme bcrypt ou Argon2, pour la comparaison des mots de passe, et retournez toujours la même réponse d’échec générique, que l’adresse e-mail soit inconnue ou que le mot de passe soit incorrect, afin que l’endpoint ne puisse pas être utilisé pour découvrir quels comptes existent.

    Étape 2 : authentifier lors de la connexion et signer un jeton

    Le gestionnaire de connexion invoque la stratégie locale via un appel de retour personnalisé. Une erreur est gérée par le système de traitement des erreurs d’Express, l’absence d’utilisateur génère un code 401, et une correspondance réussie produit un jeton signé avec l’ID et le rôle de l’utilisateur, ainsi qu’une date d’expiration tirée de JWT_EXPIRES_IN, avec une valeur par défaut de deux heures.

    const jwt = require("jsonwebtoken");
    const passport = require("passport");
    
    const login = (req, res, next) => {
      passport.authenticate("local", { session: false }, (err, user, info) => {
        if (err) return next(err);
        if (!user) return res.status(401).json({ message: info.message, });
    
        // Issue a signed JWT after successful authentication.
        const token = jwt.sign(
          { id: user.id, role: user.role,},
          process.env.JWT_SECRET,
          { expiresIn: process.env.JWT_EXPIRES_IN || "2h",}
        );
    
        return res.status(200).json({ message: "Login successful.", token, user,});
      })(req, res, next);
    };
    

    Deux détails méritent attention. La durée de validité courte limite le temps pendant lequel un jeton volé reste utile ; si les sessions doivent durer plus longtemps, associez des jetons d’accès à courte durée à un mécanisme de renouvellement tel que celui décrit dans notre stratégie de jeton de renouvellement pour les systèmes d’authentification Node.js. De plus, la réponse renvoie user tel quel. Si cet objet correspond à une ligne brute de base de données, il peut contenir le hash du mot de passe ainsi que des champs internes, ce qui représente précisément l’exposition excessive de données évoquée précédemment. Il convient plutôt de renvoyer un sous-ensemble explicite tel que l’ID, l’adresse e-mail et le rôle.

    Étape 3 : protéger les routes sécurisées

    Avec le middleware exporté, protéger une route signifie placer authenticateJWT avant le contrôleur dans la définition de la route. Les requêtes sans jeton valide sont rejetées avant que toute logique métier ne s’exécute.

    const {  Router } = require("express");
    const authRouter = Router();
    
    // Get auth middleware and sample prorected controller
    const authController = require("../controllers/auth.controller");
    const { authenticateJWT } = require("../middleware/authentication");
    
    // Use JWT as a guard to protect certain routes
    authRouter.get("/me", authenticateJWT, authController.me);
    authRouter.patch("/password", authenticateJWT, authController.updatePassword);
    

    Si vous préférez des routeurs légers, cette même protection peut être intégrée dans la chaîne de middleware du contrôleur lui-même. Dans tous les cas, faites en sorte que la protection soit la valeur par défaut pour un routeur et exemptez délibérément les routes publiques, plutôt que de devoir ajouter la protection route par route.

    2. Autorisation et scopes OAuth : déterminer ce que l’appelant peut faire

    L’authentification répond à la question « Qui êtes-vous ? » ; l’autorisation répond à « Qu’est-ce que vous êtes autorisé à faire ? ». Elle s’exécute généralement juste après l’authentification et évalue chaque identité par rapport aux règles d’accès avant d’accorder ou de refuser une demande. Sans elle, n’importe quel utilisateur connecté peut lire des données sensibles ou déclencher des actions privilégiées, ce qui est précisément la cause des vulnérabilités BOLA.

    Trois modèles couvrent la plupart des besoins :

    • Le contrôle d’accès basé sur les rôles (RBAC) attribue des permissions aux rôles et ces derniers aux utilisateurs. Une API de blogging peut comporter des rôles d’administrateur, d’éditeur et de lecteur. Ce système convient aux fonctions et groupes stables, c’est pourquoi il est courant dans les applications d’entreprise.
    • Le contrôle d’accès basé sur les attributs (ABAC) évalue les attributs de l’utilisateur, du ressource ainsi que de l’environnement de la demande (département, niveau de sensibilité de la ressource, heure de la journée, réseau) par rapport à des politiques prédéfinies. Il est adapté aux API dont les décisions dépendent fortement du contexte ou changent fréquemment.
    • Le contrôle d’accès basé sur les relations (ReBAC) accorde l’accès en fonction de la relation entre un utilisateur et une ressource spécifique, telle que la propriété ou l’appartenance à un groupe, ce qui est généralement vérifié en parcourant un graphe de relations. Ce système convient particulièrement aux produits collaboratifs comme le partage de documents ou les plateformes sociales.

    Créer soi-même une autorisation est un excellent moyen de comprendre ses subtilités, mais les systèmes de production délèguent souvent l’émission et la validation des jetons à un fournisseur d’identité. Lorsqu’une API est enregistrée auprès d’un fournisseur tel que Microsoft Entra ID et configurée pour accepter des jetons porteurs, les points de terminaison sensibles valident les scopes et les rôles de chaque jeton avant d’exécuter la requête. Un jeton invalide ou un manque de permissions entraîne une réponse 401 Unauthorized. Dans Express, le chemin protégé se présente comme ceci :

    app.get(
      "/api/orders",
      passport.authenticate("oauth-bearer", { session: false }),
      (req, res) => {
        res.json({ message: "Protected resource." });
      }
    );
    

    Il convient de se rappeler qu’un jeton validé ne définit que des permissions de base. Les vérifications au niveau de l’objet, telles que « cet ordre appartient-il à cet utilisateur ? », doivent toujours être effectuées dans votre gestionnaire ou votre couche de données, car aucun fournisseur d’identité ne sait qui possède la ligne 4812 dans votre base de données.

    Pour les protocoles eux-mêmes, fiez-vous aux normes du secteur telles que OAuth 2.0, OpenID Connect et SAML. Vous pouvez mettre en œuvre ces flux vous-même ou les déléguer à des fournisseurs d’identité tels que Ping Identity, Okta, Microsoft Entra ID, AWS ou IBM Security Verify.

    3. Chiffrement : protéger les données en transit et au repos

    Le chiffrement transforme les données lisible en texte chiffré qui est inutilisable sans la bonne clé. TLS protège les données pendant leur transmission ; le chiffrement en repos les protège là où elles sont stockées, y compris dans la base de données. Les systèmes sensibles utilisent généralement les deux, car sans chiffrement, des données telles que les identifiants financiers peuvent être interceptées ou volées depuis un stockage compromis.

    Les principales approches compromettent la vitesse au profit de la gestion des clés :

    • Le chiffrement symétrique utilise une seule clé partagée. Il est très rapide et gère bien de grands volumes, ce qui en fait le choix idéal pour le chiffrement des données en repos et des données transportées, mais les deux parties doivent conserver secrètement la même clé.
    • Le chiffrement asymétrique utilise une paire de clés publique et privée, de sorte qu’aucun secret partagé n’a besoin d’être échangé. Il est considérablement plus lent et n’est pratique que pour de petits volumes de données.
  • Chiffrement hybride combine les deux approches : la cryptographie asymétrique protège une clé symétrique, tandis que cette dernière protège les données mêmes. On bénéficie ainsi des avantages de l’échange de clés du premier type ainsi que de la vitesse du second.
  • Pour le chiffrement symétrique, le module crypto intégré à Node prend en charge AES-256-GCM. Le traitement ci-dessous serialise le corps de la requête, génère un vecteur d’initialisation de 12 octets, chiffre les données, puis renvoie l’IV, la étiquette d’authentification GCM ainsi que le texte chiffré sous forme de chaînes hexadécimales.

    const crypto = require("crypto");
    
    const algorithm = "aes-256-gcm";
    const key = Buffer.from(process.env.ENCRYPTION_KEY, "hex");
    
    app.post("/api/orders", (req, res) => {
      const iv = crypto.randomBytes(12);
    
      const cipher = crypto.createCipheriv(algorithm, key, iv);
    
      const encrypted = Buffer.concat([
        cipher.update(JSON.stringify(req.body), "utf8"),
        cipher.final(),
      ]);
    
      const payload = {
        iv: iv.toString("hex"),
        tag: cipher.getAuthTag().toString("hex"),
        data: encrypted.toString("hex"),
      };
    
      // Store or transmit the encrypted payload
      res.json(payload);
    });
    

    Plusieurs éléments garantissent la correction de cette méthode. La clé doit avoir exactement 32 octets (64 caractères hexadécimaux dans ENCRYPTION_KEY) et provenir d’un gestionnaire de secrets plutôt que du code source. L’IV doit être unique pour chaque opération de chiffrement effectuée avec la même clé ; réutiliser un IV avec GCM a des conséquences catastrophiques, c’est pourquoi il est généré à chaque demande. La étiquette d’authentification permet à la partie chargée du déchiffrement de détecter toute modification, il faut donc la stocker avec le texte chiffré et la vérifier lors du déchiffrement. Dans un service réel, ce payload serait persisté ou transmis, plutôt que d’être renvoyé à l’appelant comme le fait la démonstration.

    Le chiffrement asymétrique est également disponible dans ce même module. Les données chiffrées avec une clé publique ne peuvent être déchiffrées qu’avec la clé privée correspondante :

    const crypto = require("crypto");
    
    const encrypted = crypto.publicEncrypt(
      publicKey,
      Buffer.from("Sensitive API data")
    );
    

    Puisque RSA ne peut chiffrer qu’un en-tête de données plus petit que la taille de sa clé, publicEncrypt convient aux valeurs courtes telles qu’un champ secret ou une clé symétrique, mais pas à des documents entiers. C’est cette limitation que résout le flux de travail hybride : générer une clé AES temporaire, chiffrer l’en-tête de données avec elle, chiffrer la clé AES avec la clé publique RSA du destinataire, puis envoyer les deux. Le destinataire utilise sa clé privée pour récupérer la clé AES, puis déchiffre l’en-tête de données. La plupart des API n’auront jamais besoin de cette méthode dans leur code d’application, puisque TLS effectue déjà un échange similaire, mais il est utile de la comprendre pour les scénarios de chiffrement bout en bout.

    4. Validation et nettoyage des entrées

    Lorsque votre API accepte des données de client, vous ne pouvez pas prédire ce qui arrivera. Des en-têtes mal formatés, des fragments SQL et des charges utiles de scripts ressemblent tous à des chaînes de caractères ordinaires jusqu’à ce que quelque chose les interprète. Deux techniques complémentaires permettent de faire face à ce problème. La validation rejette les entrées qui violent vos règles structurelles et sémantiques. La sanitisation transforme les entrées acceptées en une forme sécurisée et normalisée avant qu’elles n’atteignent vos traitements.

    Imposer le type de contenu en premier

    La vérification la plus simple concerne le format de la requête elle-même. Cette petite factory de middleware utilise req.is() pour confirmer le Content-Type et répond par 415 Unsupported Media Type dans le cas contraire. Elle peut être intégrée de manière globale, par routeur ou par point de terminaison.

    const requireContentType = (type) => (req, res, next) => {
      if (!req.is(type)) {
        return res.status(415).json({ error: "Unsupported Media Type", });
      }
    
      next();
    };
    
    app.post("/api/users", requireContentType("application/json"),
      (req, res) => {
        res.json({ message: "User created." });
      }
    );
    

    Vérifier la structure et le sens du corps de la requête

    Avec le format imposé, la couche suivante vérifie que la requête est bien formulée. En utilisant express-validator, conservez les règles dans un module de validation dédié. Celui-ci exige un e-mail syntaxiquement valide, effectue une vérification asynchrone personnalisée qui rejette les adresses déjà présentes dans la base de données, et impose une longueur minimale de mot de passe de huit caractères.

    const { body } = require("express-validator");
    const { getUserEmail } = require("../db/queries");
    
    const validateRegistration = [
      body("email")
        .isEmail()
        .withMessage("Invalid email format")
        .custom(async (value) => {
          if (await getUserEmail(value)) {
            throw new Error("Email is already in use");
          }
          return true;
        }),
    
      body("password")
        .isLength({ min: 8 })
        .withMessage("Password must be at least 8 characters long"),
    ];
    
    module.exports = { validateRegistration }
    

    L’array de validateurs est ensuite inséré dans la chaîne de middleware de la route. À l’intérieur du gestionnaire, validationResult(req) collecte tous les échecs, et la route renvoie une réponse 400 contenant la liste complète plutôt que de continuer son exécution.

    const { validationResult } = require("express-validator");
    const { validateRegistration } = require("../validators/userValidator");
    
    app.post("/api/register", validateRegistration, (req, res) => {
      const errors = validationResult(req);
    
      if (!errors.isEmpty()) {
        return res.status(400).json({ errors: errors.array() });
      }
    
      res.json({ message: "Registration successful." });
    });
    

    Sanitisation après validation

    Comme Express traite les middleware dans l’ordre, une chaîne de sanitisation peut être placée juste après la validation. Ici, le prénom est épuré et échappé en HTML, tandis que l’adresse e-mail est normalisée.

    const sanitizeRegistration = [
      body("firstName").trim().escape(),
      body("email").normalizeEmail(),
    ];
    
    app.post(
      "/api/register",
      validateRegistration,
      sanitizeRegistration,
      (req, res) => {
        const errors = validationResult(req);
    
        if (!errors.isEmpty()) {
          return res.status(400).json({ errors: errors.array() });
        }
    
        res.json({ message: "Registration successful." });
      }
    );
    

    Le ordre des opérations est important ici de manière subtile. La vérification d’unicité dans le validateur s’exécute avant normalizeEmail(), ce qui peut permettre à deux orthographes identiques d’une même adresse de passer inaperçues et de créer des comptes dupliqués. Normaliser les données avant la recherche, ou imposer l’unicité sur la valeur normalisée au niveau de la base de données, comble cette faille. Faites également attention à l’utilisation de escape() : l’encodage HTML des entrées protège les templates qui affichent ces valeurs, mais il modifie les données stockées ; de nombreuses équipes préfèrent stocker des valeurs brutes et effectuer l’encodage à l’affichage. Si vous envisagez d’utiliser des bibliothèques de validation, notre comparaison de Zod et express-validator décrit les avantages et inconvénients respectifs.

    Utilisez des requêtes paramétrées pour la base de données

    Ne jamais construire de requêtes SQL en concaténant des entrées utilisateur. Les clients de base de données tels que pg et les ORM tels que Prisma prennent en charge les requêtes paramétrées, qui envoient le texte de la requête et les valeurs séparément, de sorte que la base de données traite toujours les entrées comme des données et jamais comme du SQL exécutable.

    Avec pg, créez un pool de connexions une seule fois et exportez-le pour vos modules de données :

    const { Pool } = require("pg");
    
    const pool = new Pool({
      connectionString: process.env.DATABASE_URL,
    });
    
    module.exports = pool;
    

    Les requêtes utilisent ensuite des placeholders numérotés ($1, $2) auxquels sont associés les valeurs fournies dans un tableau distinct. Même si email contient une citation suivie de DROP TABLE, il est stocké en tant que chaîne de caractères littérale.

    app.post("/api/users", async (req, res) => {
      const { email, name } = req.body;
    
      await pool.query(
        "INSERT INTO users (email, name) VALUES ($1, $2)",
        [email, name]
      );
    
      res.status(201).json({ message: "User created." });
    });
    

    Ces éléments combinés forment un pipeline en plusieurs niveaux. Express-validator vous permet d’isoler les règles en unités réutilisables qui s’exécutent en tant que middleware et signalent chaque échec, tandis que la paramétrisation garantit que même les données qui échappent à la validation ne peuvent pas modifier vos requêtes.

    5. Limitation des débits et throttling

    La limitation des débits fixe le nombre de requêtes qu’un client peut envoyer dans une fenêtre de temps donnée. Elle atténue les tentatives de force brute et de refus de service, et empêche un utilisateur gourmand de ralentir tous les autres.

    Des limites peuvent être appliquées selon différentes dimensions :

    • Pour chaque client : les requêtes sont comptées par clé API ou adresse IP. Lorsqu’un client atteint la limite, il doit attendre que la fenêtre se réinitialise ou obtenir une quota plus élevée, généralement dans une version payante.
    • Par géographie ou par période : les limites varient selon la région ou la fenêtre temporelle, permettant par exemple un volume de trafic plus élevé en provenance des régions où se trouvent vos clients et restreignant les limites là où provient un trafic suspect.
    • Par capacité du serveur : certaines parties d’une API sont acheminées vers une infrastructure dédiée disposant de ses propres limites, comme un petit groupe de travailleurs en arrière-plan pour les tâches coûteuses.

    De nombreux algorithmes existent (fenêtre fixe, fenêtre glissante, bucket de jetons), et vous n’avez pas besoin de les implémenter vous-même pour commencer. Le middleware express-rate-limit compte par défaut les requêtes par adresse IP. L’exemple ci-dessous définit un budget général de 100 requêtes toutes les 15 minutes pour tout ce qui se trouve sous /api, ainsi que des restrictions beaucoup plus strictes de cinq tentatives toutes les cinq minutes pour les connexions de connexion, avec un message personnalisé pour les requêtes rejetées.

    const rateLimit = require("express-rate-limit");
    
    // Apply to all API routes
    const apiLimiter = rateLimit({
      windowMs: 15 * 60 * 1000, // 15 minutes
      max: 100,
    });
    
    // Apply stricter limits to authentication endpoints
    const loginLimiter = rateLimit({
      windowMs: 5 * 60 * 1000, // 5 minutes
      max: 5,
      message: "Too many login attempts. Please try again later.",
    });
    
    app.use("/api", apiLimiter);
    
    app.post("/api/login", loginLimiter, (req, res) => {
      res.json({ message: "Login successful." });
    });
    

    Les API publiques attribuent généralement une clé à chaque utilisateur, et limiter l’accès en fonction de cette clé est plus équitable que de le faire en fonction de l’IP, car de nombreux utilisateurs peuvent partager une même adresse derrière un proxy d’entreprise. Un keyGenerator personnalisé lit l’en-tête X-API-Key et l’utilise comme identifiant du compteur.

    const rateLimit = require("express-rate-limit");
    
    const apiKeyLimiter = rateLimit({
      windowMs: 15 * 60 * 1000,
      max: 1000,
      keyGenerator: (req) => req.get("X-API-Key"),
    });
    
    app.use("/api", apiKeyLimiter);
    

    Comme indiqué, toute demande omettant l’en-tête produit la même clé undefined et partage un même bucket. En pratique, il convient de rejeter les demandes sans clé plus tôt dans la chaîne de traitement ou de recourir à l’adresse IP.

    Régulation du nombre de requêtes vers des endpoints coûteux

    La régulation détermine la vitesse à laquelle les requêtes sont acceptées afin d’éviter que des pics soudains ne submergent le service. La configuration suivante utilise le même middleware avec des intervalles très courts : au maximum dix requêtes par seconde pour l’ensemble de l’API, et une seule requête de recherche toutes les deux secondes par client, car la recherche est l’endpoint le plus gourmand en ressources.

    const rateLimit = require("express-rate-limit");
    
    // Throttle all API requests
    const apiThrottle = rateLimit({
      windowMs: 1000, // 1 second
      max: 10, // Allow up to 10 requests per second
    });
    
    // Apply a stricter throttle to resource-intensive endpoints
    const searchThrottle = rateLimit({
      windowMs: 2000, // 2 seconds
      max: 1, // Allow 1 request every 2 seconds
      message: "Please wait before sending another search request.",
    });
    
    app.use("/api", apiThrottle);
    
    app.get("/api/search", searchThrottle, (req, res) => {
      res.json({ results: [] });
    });
    

    En termes stricts, il s’agit toujours d’une limitation de débit avec de courtes fenêtres temporelles : les demandes excédentaires sont rejetées avec un code 429, sans être retardées. Si vous souhaitez un véritable mécanisme de ralenti qui ralentit les clients avant de les refuser, un package complémentaire comme express-slow-down ajoute des retards progressifs. Notez également que le stockage en mémoire par défaut compte les requêtes par processus ; par conséquent, derrière un équilibreur de charge disposant de plusieurs instances, vous avez besoin d’un stockage partagé comme Redis pour que les limites soient respectées. Les dernières versions de express-rate-limit renomment également l’option max en limit ; consultez la documentation de la version que vous installez. Pour une alternative légère en TypeScript, consultez notre article sur un limiteur de débit minimal pour Express.

    6. Journalisation, surveillance et détection d’incidents

    Vous ne pouvez pas répondre à une attaque que vous ne voyez jamais. Le journalisation enregistre les requêtes et les réponses avec leurs métadonnées, leur contexte, leurs temps d’exécution et leurs codes d’erreur, afin que vous puissiez diagnostiquer les problèmes, effectuer des audits et comprendre l’utilisation réelle. Le suivi surveille les activités en temps réel, enregistre des indicateurs tels que la latence, les taux d’erreur et le débit, et met en évidence des anomalies pouvant signaler un abus, l’incapacité à atteindre les objectifs de niveau de service ou l’exploitation d’une vulnérabilité.

    Les principales approches, avec leurs avantages et inconvénients :

    • Journalisation des requêtes à l’aide de middleware comme Morgan permet d’enregistrer chaque requête HTTP entrante à moindre coût, mais ne fournit aucune information sur la santé du système.
    • Journalisation des applications avec Winston, Pino ou Bunyan enregistre les événements métier et de sécurité tels que les connexions, les opérations sur la base de données et les erreurs. Les systèmes plus importants nécessitent une sortie structurée et un stockage centralisé pour en tirer parti.
    • Métriques avec Prometheus et Grafana suivent les taux de requêtes, la latence, la consommation de CPU, de mémoire ainsi que les taux d’erreur. Ils montrent des tendances globales mais pas ce qui s’est passé au sein d’une seule requête.
    • Gestion centralisée des journaux avec l’ELK Stack, OpenSearch, Splunk ou CloudWatch agrège les journaux provenant de différents services, au prix d’une infrastructure supplémentaire et d’un travail opérationnel accru.
    • Surveillance des performances des applications avec Datadog, New Relic ou Dynatrace combine journaux, métriques, traces et fonctionnalités d’alerte sur une même plateforme, ce qui entraîne des coûts de licence plus élevés et une complexité accrue de la plateforme.

    Chacun de ces éléments se connecte à Express. Voici quatre composants courants.

    Journalisation des requêtes avec Morgan

    En enregistrant Morgan au format combined, une ligne de style Apache est écrite pour chaque requête, incluant la méthode, le chemin, le statut, la taille de la réponse et l’agent utilisateur.

    const express = require("express");
    const morgan = require("morgan");
    
    const app = express();
    
    // Log every incoming request
    app.use(morgan("combined"));
    
    app.get("/api/users", (req, res) => {
      res.json({ message: "Users retrieved successfully." });
    });
    

    Journalisation structurée des applications avec Winston

    Winston enregistre les événements sous forme d’objets structurés. En journalisant les identifiants de l’utilisateur et de la commande lors de sa création, on crée un historique d’audit que l’on peut rechercher ultérieurement.

    const winston = require("winston");
    
    const logger = winston.createLogger({
      transports: [
        new winston.transports.Console(),
      ],
    });
    
    app.post("/api/orders", (req, res) => {
      logger.info("Order created", {
        userId: req.user.id,
        orderId: req.body.id,
      });
    
      res.status(201).json({ message: "Order created." });
    });
    

    Faites attention à ce qui est enregistré dans les journaux. Les identifiants d’utilisateurs sont acceptables ; les mots de passe, les tokens, les numéros complets de carte et l’ensemble du corps de la requête ne le sont pas, et les journaux constituent une source fréquente de fuites de données sensibles.

    Journalisation centralisée des erreurs

    Un middleware de gestion des erreurs d’Express, reconnaissable à ses quatre arguments, capture les erreurs provenant de n’importe quelle route. Celui-ci enregistre le message accompagné du chemin et de la méthode, puis renvoie une erreur générique 500 afin que les traces d’exécution et les détails internes n’atteignent jamais le client.

    app.use((err, req, res, next) => {
    /* Logger is built as an independent module or class */
      logger.error(err.message, {
        path: req.originalUrl,
        method: req.method,
      });
    
      res.status(500).json({
        error: "Internal Server Error",
      });
    });
    

    Exposition des métriques pour Prometheus

    La bibliothèque prom-client collecte les métriques par défaut du processus Node.js et les expose sur une adresse /metrics afin que Prometheus puisse les récupérer.

    const client = require("prom-client");
    
    client.collectDefaultMetrics();
    
    app.get("/metrics", async (req, res) => {
      res.set("Content-Type", client.register.contentType);
      res.end(await client.register.metrics());
    });
    

    Cet endpoint révèle des détails internes concernant votre service, il convient donc de le restreindre au réseau de surveillance ou de le protéger par une authentification, plutôt que de le laisser public.

    Les frameworks préconisant des solutions standardisées sont utiles à cet égard. NestJS intègre un générateur de logs et une structure qui facilitent l’ajout de fonctionnalités de journalisation et de suivi pour chaque endpoint, tandis qu’avec un framework moins structuré comme Express, vous devez ajouter manuellement des mécanismes de journalisation, généralement sous forme de middleware exécuté avant l’envoi de la réponse. La détection d’incidents s’appuie ensuite sur ces logs : des alertes sont déclenchées pour des schémas suspects, tels que des pics de réponses 401 ou des rejets dus à des limites de fréquence, et ces alertes sont envoyées vers le canal de notification que votre équipe surveille réellement.

    La sécurité en tant que processus continu

    Aucun article ne couvre tout, mais avant toute publication, vous pouvez vous assurer que votre API répond à ces critères de base :

    • Chaque point de terminaison est accessible uniquement via HTTPS.
    • OAuth ou un flux de token équivalent est mis en œuvre.
    • Les JWT émis ont une date d’expiration.
    • Des limites protègent toutes les routes, plus strictes pour la connexion.
    • Chaque entrée est validée avant utilisation.
    • Les requêtes ont été testées pour détecter les tentatives d’injection.
    • Les règles d’accès ont été testées, y compris les vérifications au niveau de l’objet.
    • Les clés et les secrets se trouvent en dehors du code source.
    • Les événements liés à la sécurité sont enregistrés.
    • Des tableaux de bord et des alertes ont été configurés.
    • Les réponses incluent des en-têtes de sécurité.
    • Les clients ne voient jamais les traces d’erreur ni les détails des erreurs internes.
    • Les dépendances sont à jour et ont fait l’objet d’audits.

    Express a été utilisé ici à titre d’exemple, mais les mêmes principes s’appliquent également à d’autres frameworks backend, la plupart desquels intègrent directement ces outils ou proposent des équivalents natifs. NestJS, par exemple, gère la validation des requêtes au moyen d’objets de transfert de données. La documentation de votre framework indiquera la version idiomatique de chaque couche.

    Ces mêmes principes constituent également la base des déploiements cloud natives sur des plateformes telles qu’Azure, Google Cloud et AWS, qui ajoutent leurs propres passerelles, services d’identification et mécanismes de limitation de débit gérés. Les pratiques spécifiques au cloud méritent un traitement distinct, mais les couches mentionnées ci-dessus suffisent déjà à rendre une API largement conforme aux exigences de sécurité.

    Un bref audit de sécurité pour une API Express

    • Séparer la vérification des identifiants de l’authentification par requête : vérifier le mot de passe une seule fois, puis s’appuyer sur des tokens signés à durée de vie courte.
    • L’authentification n’est pas l’autorisation. Validez les scopes et les rôles, et vérifiez néanmoins la propriété de chaque objet touché par une demande.
    • Utilisez AES-GCM avec un IV unique par opération pour les données au repos ; réservez le chiffrement asymétrique aux petites valeurs et à l’échange de clés.
    • Intégrez des vérifications du type de contenu, des validations, des nettoyages ainsi que des requêtes paramétrées, et réfléchissez à l’ordre dans lequel elles s’exécutent.
    • Limitez le nombre de requêtes pour tout, appliquez des limites plus strictes aux connexions de connexion et aux endpoints coûteux, et utilisez un stockage partagé lorsque vous exécutez plus d’une instance.
    • Enregistrez les événements liés à la sécurité sans consigner de secrets, et transformez ces journaux en alertes sur lesquelles quelqu’un pourra agir.

    Lectures complémentaires

  • Authentification biometrique améliorée sur iOS et Android avec des clés liées au hardware : Découvrez pourquoi le Face ID seul ne prouve rien à votre serveur, et comment utiliser les clés Secure Enclave et Android Keystore pour signer des défis uniques destinés au serveur.
  • De l’upload à l’URL : Stockage et service des fichiers utilisateurs en sécurité dans Express : Apprenez où les applications Express doivent conserver les fichiers téléchargés, comment express.static mappe un dossier vers des URLs, et quels mécanismes de protection empêchent que les téléchargements des utilisateurs ne deviennent une faille de sécurité.
  • Contrôle d’accès basé sur les rôles dans Express avec JWT et deux middleware : Apprenez comment imposer un contrôle d’accès basé sur les rôles dans une API Express en combinant un middleware d’authentification JWT avec un mécanisme authorize() réutilisable, ainsi que savoir quand répondre 401 plutôt que 403.
  • Déplacer le JWT d’une application React hors de localStorage vers des cookies HttpOnly : Découvrez pourquoi les JWT stockés dans localStorage sont vulnérables aux attaques XSS, comment les cookies HttpOnly avec les paramètres SameSite, de rotation et un BFF réduisent les risques, et quels pièges persistent encore dans les applications React.