DEV Community

Cover image for Créer une API REST Node.js avec Express en 2026 : le tutoriel pas à pas
Neurau Tech
Neurau Tech

Posted on Originally published at neurautech.com

Créer une API REST Node.js avec Express en 2026 : le tutoriel pas à pas

L'essentiel en une minute

Créer une API REST Node.js en 2026, c'est d'abord choisir les bonnes versions : Node.js 24 est la LTS la plus récente au 24 octobre 2026, et Express 5.3.0 est sortie le 9 octobre. La première version de cet article reposait sur Node.js 20, arrivé en fin de vie le 30 avril 2026 : tout le code a été réécrit.

Ce tutoriel construit une petite API de gestion de tâches, de l'initialisation au déploiement. Il s'appuie sur la documentation de Node.js et d'Express, sur MDN pour HTTP et sur l'OWASP pour la sécurité, consultées le 24 octobre 2026. Le code suit ces documentations, mais nous ne l'avons pas exécuté.

Points clés

  • Visez Node.js 24 LTS aujourd'hui, ou Node.js 26 à partir du 28 octobre 2026, date à laquelle il doit passer en LTS selon le calendrier officiel. Express 5 exige Node.js 18 au minimum.
  • Express 5 transmet automatiquement les erreurs des fonctions async au gestionnaire d'erreurs : plus besoin d'envelopper chaque route dans un try/catch.
  • Le module node:sqlite, intégré à Node.js, suffit pour apprendre sans dépendance, mais il est encore au stade « release candidate » et synchrone : passez à PostgreSQL en production.
  • Pour l'authentification, l'OWASP demande de refuser alg: none, de vérifier iss, aud et exp, et de hacher les mots de passe avec Argon2id, ou à défaut scrypt.
  • Le lanceur de tests intégré à Node.js, stable depuis la version 20, et fetch suffisent pour tester une API REST Node.js sans framework de test.

Les principes REST et les codes de statut

REST n'est pas une norme mais un ensemble de contraintes d'architecture. MDN précise qu'un débutant peut voir une API REST comme un service HTTP appelable avec les outils web standard (glossaire MDN, REST, mis à jour le 11 juillet 2025). En pratique : chaque ressource a une URL (/api/v1/taches/42), le verbe HTTP dit ce qu'on en fait, le code de statut dit comment ça s'est passé.

Verbe Usage dans notre API Idempotent Code en cas de succès
GET Lire la liste ou une tâche Oui 200
POST Créer une tâche Non 201
PUT Remplacer une tâche entière Oui 200
PATCH Modifier une partie d'une tâche Non garanti 200
DELETE Supprimer une tâche Oui 204

Selon MDN, les méthodes sûres, PUT et DELETE sont idempotentes, alors que POST et PATCH ne le sont pas forcément (glossaire MDN, Idempotent) : deux PUT identiques laissent la ressource dans le même état, deux POST créent deux tâches.

Les codes de statut forment cinq classes : informations (1xx), succès (2xx), redirections (3xx), erreurs du client (4xx) et du serveur (5xx), selon la référence MDN des codes de statut. Voici ceux de notre API :

Code Sens selon MDN Quand notre API le renvoie
201 Created Une nouvelle ressource a été créée Après un POST réussi
204 No Content Pas de contenu à envoyer Après une suppression
400 Bad Request Requête incompréhensible, syntaxe invalide JSON mal formé
401 Unauthorized Le client doit s'authentifier Jeton absent, invalide ou expiré
404 Not Found Ressource introuvable Tâche inexistante ou d'un autre utilisateur
409 Conflict Conflit avec l'état du serveur E-mail déjà inscrit
413 Content Too Large Corps de requête plus grand que la limite du serveur Corps de plus de 10 ko
415 Unsupported Media Type Format de données non supporté Corps absent ou qui n'est pas du JSON
422 Unprocessable Content Requête bien formée mais erreurs sémantiques Données refusées par la validation
429 Too Many Requests Trop de requêtes en un temps donné Trop de tentatives d'inscription ou de connexion
500 Internal Server Error Situation que le serveur ne sait pas traiter Bug ou panne imprévue

La fiche REST Security de l'OWASP tranche le piège classique : 401 quand l'authentification manque ou échoue, 403 quand elle a réussi mais que l'utilisateur n'a pas les droits. Pour la tâche d'un autre utilisateur, notre API répond 404, ce qui évite de confirmer qu'elle existe.


Pourquoi une API REST Node.js avec Express en 2026

Node.js reste la technologie web la plus citée par les développeurs, et Express le framework serveur le plus utilisé de cet écosystème. Dans l'enquête Stack Overflow 2025, 48,7 % des 23 678 répondants à la question sur les technologies web déclarent avoir beaucoup travaillé avec Node.js sur l'année écoulée, et 19,9 % avec Express. NestJS est à 6,7 % et Fastify à 2,9 %.

Graphique en barres : usage de technologies serveur dans l'enquête Stack Overflow 2025, Node.js 48,7 %, Express 19,9 %, ASP.NET Core 19,7 %, FastAPI 14,8 %, Spring Boot 14,7 %, Flask 14,4 %, Django 12,6 %, NestJS 6,7 %, Fastify 2,9 %

L'édition 2026, menée du 23 juin au 5 août 2026 selon sa méthodologie, ne publie pas de classement des frameworks web : ces chiffres de 2025 restent les plus récents de cette source. Elle confirme que JavaScript est le langage le plus utilisé, par 62,0 % des 14 099 répondants à la question (Stack Overflow 2026).

Express n'est ni le plus rapide ni le plus structurant, mais sa simplicité en fait un bon outil pour apprendre : un routeur, des middlewares, rien d'imposé. Côté Python, l'équivalent serait Flask ou FastAPI, présentés dans notre guide pour apprendre Python en 2026.


Étape 1 : installer Node.js et initialiser le projet

Installez une version LTS : la page des versions de Node.js réserve la production aux versions LTS, actives ou en maintenance.

Composant Version au 24 octobre 2026 Statut
Node.js 24 « Krypton » 24.22.1, publiée le 20 octobre 2026 LTS, en maintenance depuis le 20 octobre, fin prévue le 30 avril 2028
Node.js 26 26.12.1, publiée le 21 octobre 2026 Current, passage en LTS prévu le 28 octobre 2026
Node.js 22 « Jod » 22.23.4 LTS de maintenance, fin prévue le 30 avril 2027
Node.js 20 20.20.2 Fin de vie
Express 5 5.3.0, publiée le 9 octobre 2026 Version par défaut sur npm, Node.js 18 minimum

Sources : le calendrier officiel des versions, les notes de version d'Express et sa page de support. À partir de Node.js 27, chaque version majeure deviendra LTS, sur un cycle annuel. Créez ensuite le projet :

node --version
mkdir api-taches && cd api-taches
npm init -y
npm install express zod jsonwebtoken helmet rate-limiter-flexible
Enter fullscreen mode Exit fullscreen mode

Au 24 octobre 2026, le registre npm sert Express 5.3.0, Zod 4.6.7, jsonwebtoken 9.0.3, Helmet 8.3.0 et rate-limiter-flexible 11.2.3. Complétez package.json ainsi :

{
  "name": "api-taches",
  "version": "1.0.0",
  "type": "module",
  "engines": { "node": ">=24" },
  "scripts": {
    "dev": "node --watch --env-file=.env src/server.js",
    "start": "node --env-file-if-exists=.env src/server.js",
    "test": "node --test"
  },
  "dependencies": {
    "express": "^5.3.0",
    "helmet": "^8.3.0",
    "jsonwebtoken": "^9.0.3",
    "rate-limiter-flexible": "^11.2.3",
    "zod": "^4.6.7"
  }
}
Enter fullscreen mode Exit fullscreen mode

Plus besoin de nodemon ni de dotenv : selon la documentation de la ligne de commande, --watch est stable depuis Node.js 22, et --env-file comme --env-file-if-exists ne sont plus expérimentaux depuis Node.js 24.10 (journal des modifications de la 24.10.0). Le script start charge .env s'il existe, sans échouer en production où les variables sont injectées par l'hébergeur. Créez enfin un fichier .env, exclu de Git :

PORT=3000
DB_PATH=data.db
JWT_SECRET=collez-ici-une-valeur-aleatoire
Enter fullscreen mode Exit fullscreen mode

Pour générer le secret, node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))" produit 256 bits aléatoires, la taille minimale qu'impose la RFC 7518, section 3.2 pour une clé HS256 ; sans cette valeur, server.js refuse de démarrer. Pour l'éditeur, voyez notre sélection d'extensions VS Code.


Étape 2 : créer le serveur Express 5

L'application et son démarrage vivent dans deux fichiers, pour que les tests importent l'application sans ouvrir de port. L'arborescence finale :

api-taches/
  package.json
  .env
  src/
    app.js            application Express
    server.js         démarrage
    db.js             base SQLite
    schemas.js        schémas Zod
    motdepasse.js     hachage scrypt
    middleware/       validation, auth, limiteur, erreurs
    routes/           auth.js, taches.js
  test/
    api.test.js
Enter fullscreen mode Exit fullscreen mode

Le fichier src/app.js assemble les middlewares et les routeurs :

import express from 'express';
import helmet from 'helmet';
import { routerAuth } from './routes/auth.js';
import { routerTaches } from './routes/taches.js';
import { routeInconnue, gestionErreurs } from './middleware/erreurs.js';

export const app = express();

if (process.env.TRUST_PROXY) {
  app.set('trust proxy', Number(process.env.TRUST_PROXY));
}

app.use(helmet());
app.use(express.json({ limit: '10kb' }));

app.get('/health', (req, res) => {
  res.json({ statut: 'ok' });
});

app.use('/api/v1/auth', routerAuth);
app.use('/api/v1/taches', routerTaches);

app.use(routeInconnue);
app.use(gestionErreurs);
Enter fullscreen mode Exit fullscreen mode

Helmet ajoute des en-têtes de sécurité et retire X-Powered-By, comme le recommande la page sécurité en production d'Express. express.json() limite le corps à 100 ko par défaut selon la référence de l'API Express 5 : 10 ko suffisent pour une tâche. Le préfixe /api/v1 permettra une version 2 sans casser les clients. Derrière un proxy inverse, TRUST_PROXY=1 indique à Express de lire l'adresse du client dans X-Forwarded-For à un saut de distance, comme le décrit la page Express derrière un proxy ; sans proxy, laissez la variable vide, sinon un client pourrait falsifier son adresse.

Le fichier src/server.js démarre le serveur :

import { app } from './app.js';

if (Buffer.byteLength(process.env.JWT_SECRET ?? '') < 32) {
  throw new Error('JWT_SECRET doit contenir au moins 32 octets (256 bits)');
}

const port = Number(process.env.PORT ?? 3000);

app.listen(port, (error) => {
  if (error) throw error;
  console.log(`API à l'écoute sur http://localhost:${port}`);
});
Enter fullscreen mode Exit fullscreen mode

Le paramètre error est une nouveauté d'Express 5 : selon le guide de migration, app.listen appelle ce rappel en cas d'erreur du serveur, comme un port déjà occupé.


Étape 3 : stocker les données dans SQLite

Node.js embarque SQLite depuis la version 22.5, dans le module node:sqlite, sans option à activer depuis les versions 22.13 et 23.4. La documentation de Node.js 24 le classe « release candidate » (stabilité 1.2) depuis la version 24.15, publiée le 15 avril 2026 (journal des modifications de la 24.15.0). Pour un tutoriel, c'est le plus simple : zéro dépendance, zéro serveur.

import { DatabaseSync } from 'node:sqlite';

export const db = new DatabaseSync(process.env.DB_PATH ?? 'data.db');

db.exec(`
  CREATE TABLE IF NOT EXISTS utilisateurs (
    id INTEGER PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    hash TEXT NOT NULL
  );
  CREATE TABLE IF NOT EXISTS taches (
    id INTEGER PRIMARY KEY,
    titre TEXT NOT NULL,
    terminee INTEGER NOT NULL DEFAULT 0,
    proprietaire INTEGER NOT NULL REFERENCES utilisateurs(id)
  );
`);
Enter fullscreen mode Exit fullscreen mode

Les clés étrangères sont actives par défaut, selon la même page. Toutes les requêtes passent par db.prepare() avec des paramètres ? : les valeurs sont liées à part du texte SQL, ce qui bloque l'injection SQL.

Deux limites. Sous Node.js 26, la classe s'appelle Database et DatabaseSync devient un alias déprécié dans la documentation seulement (DEP0210). Surtout, l'API est synchrone : chaque requête bloque la boucle d'événements, alors que la page performances en production d'Express déconseille les fonctions synchrones.

Graphique en sucettes : bases de données candidates pour une API REST Node.js, les plus utilisées selon l'enquête Stack Overflow 2026, PostgreSQL 57,9 %, SQLite 42,1 %, MySQL 35,1 %, Redis 28,3 %, Microsoft SQL Server 27,1 %, MariaDB 20,3 %, MongoDB 19,0 %

Pour une API REST Node.js en production avec du trafic concurrent, préférez PostgreSQL, la base la plus utilisée : 57,9 % des 12 061 répondants concernés de l'enquête Stack Overflow 2026, devant SQLite (42,1 %) et MySQL (35,1 %) ; MongoDB est à 19,0 %. Il faudra un pilote asynchrone et des await, mais les routes gardent la même forme.


Étape 4 : écrire les routes et les verbes HTTP

Les routes traduisent le tableau des verbes en code. Toutes passent par exigerAuth : chaque utilisateur ne voit que ses tâches. Les middlewares utilisés sont écrits aux étapes 5 et 7. Voici src/routes/taches.js :

import { Router } from 'express';
import { db } from '../db.js';
import { exigerAuth } from '../middleware/auth.js';
import { exigerJson, valider } from '../middleware/validation.js';
import { schemaCreation, schemaRemplacement, schemaModification } from '../schemas.js';

export const routerTaches = Router();
routerTaches.use(exigerAuth);

const versJson = (t) => ({ id: t.id, titre: t.titre, terminee: t.terminee === 1 });
const introuvable = (res) => res.status(404).json({ erreur: 'Tâche introuvable' });

function lireId(req) {
  const id = Number(req.params.id);
  return Number.isSafeInteger(id) && id > 0 ? id : null;
}

function trouver(id, proprietaire) {
  return db
    .prepare('SELECT id, titre, terminee FROM taches WHERE id = ? AND proprietaire = ?')
    .get(id, proprietaire);
}

routerTaches.get('/', (req, res) => {
  const taches = db
    .prepare('SELECT id, titre, terminee FROM taches WHERE proprietaire = ? ORDER BY id')
    .all(req.utilisateurId);
  res.json(taches.map(versJson));
});

routerTaches.get('/:id', (req, res) => {
  const id = lireId(req);
  const tache = id && trouver(id, req.utilisateurId);
  if (!tache) return introuvable(res);
  res.json(versJson(tache));
});

routerTaches.post('/', exigerJson, valider(schemaCreation), (req, res) => {
  const { titre, terminee = false } = req.donnees;
  const { lastInsertRowid } = db
    .prepare('INSERT INTO taches (titre, terminee, proprietaire) VALUES (?, ?, ?)')
    .run(titre, terminee ? 1 : 0, req.utilisateurId);
  const id = Number(lastInsertRowid);
  res.status(201).location(`${req.baseUrl}/${id}`).json({ id, titre, terminee });
});

function mettreAJour(req, res) {
  const id = lireId(req);
  const actuelle = id && trouver(id, req.utilisateurId);
  if (!actuelle) return introuvable(res);
  const tache = { ...versJson(actuelle), ...req.donnees };
  db.prepare('UPDATE taches SET titre = ?, terminee = ? WHERE id = ?')
    .run(tache.titre, tache.terminee ? 1 : 0, id);
  res.json(tache);
}

routerTaches.put('/:id', exigerJson, valider(schemaRemplacement), mettreAJour);
routerTaches.patch('/:id', exigerJson, valider(schemaModification), mettreAJour);

routerTaches.delete('/:id', (req, res) => {
  const id = lireId(req);
  if (!id) return introuvable(res);
  const { changes } = db
    .prepare('DELETE FROM taches WHERE id = ? AND proprietaire = ?')
    .run(id, req.utilisateurId);
  if (Number(changes) === 0) return introuvable(res);
  res.sendStatus(204);
});
Enter fullscreen mode Exit fullscreen mode

Le guide de migration vers Express 5 retire res.json(objet, 201) au profit de res.status(201).json(objet), et res.send(204) au profit de res.sendStatus(204). L'en-tête Location du POST donne au client l'URL de la ressource créée.

La syntaxe des chemins a aussi changé : un joker doit être nommé (/*splat au lieu de /*) et un paramètre optionnel s'écrit entre accolades. Pour migrer un projet Express 4, le guide propose npx codemod@latest @expressjs/v5-migration-recipe.


Étape 5 : valider les données avec Zod

Ne faites jamais confiance au corps d'une requête. La fiche REST de l'OWASP demande de valider longueur, plage, format et type de chaque entrée, et de refuser en 415 un Content-Type inattendu. Les schémas vont dans src/schemas.js :

import * as z from 'zod';

const titre = z.string().trim().min(1).max(200);

export const schemaIdentifiants = z.strictObject({
  email: z.string().trim().toLowerCase().pipe(z.email()),
  motDePasse: z.string().min(15).max(128),
});

export const schemaCreation = z.strictObject({ titre, terminee: z.boolean().optional() });
export const schemaRemplacement = z.strictObject({ titre, terminee: z.boolean() });
export const schemaModification = schemaRemplacement.partial();
Enter fullscreen mode Exit fullscreen mode

z.strictObject refuse les clés inconnues : impossible d'injecter un champ proprietaire. L'e-mail est nettoyé et mis en minuscules avant d'être validé par z.email(), la syntaxe de Zod 4 (référence de l'API Zod), pour qu'Ada@Example.com et ada@example.com désignent le même compte. Le minimum de 15 caractères suit la fiche Authentication de l'OWASP et la norme NIST SP 800-63B, révision 4 (section 3.1.1.2) pour un mot de passe utilisé sans second facteur ; le maximum doit rester d'au moins 64 caractères. Le middleware src/middleware/validation.js range le résultat nettoyé dans req.donnees :

import * as z from 'zod';

export function exigerJson(req, res, next) {
  if (!req.is('application/json')) {
    return res.status(415).json({ erreur: 'Le corps doit être en application/json' });
  }
  next();
}

export function valider(schema) {
  return (req, res, next) => {
    const resultat = schema.safeParse(req.body);
    if (!resultat.success) {
      return res.status(422).json({
        erreur: 'Données invalides',
        details: z.flattenError(resultat.error),
      });
    }
    req.donnees = resultat.data;
    next();
  };
}
Enter fullscreen mode Exit fullscreen mode

safeParse() renvoie success puis data ou error sans lever d'exception. z.flattenError() produit deux listes, selon la page de Zod sur le formatage des erreurs : fieldErrors, champ par champ, et formErrors, pour les erreurs à la racine de l'objet. C'est là qu'arrive une clé inconnue refusée par z.strictObject, d'où l'envoi des deux listes au client.

Le contrôle du Content-Type sert aussi Express 5 : req.body y vaut undefined quand le corps n'a pas été analysé, là où Express 4 renvoyait {}. Sans lui, une requête en text/plain recevrait un 422 trompeur. Une requête sans corps du tout reçoit elle aussi un 415, car req.is() renvoie null quand il n'y a pas de corps, selon la référence de l'objet requête : c'est voulu, ces routes exigent un objet JSON.


Étape 6 : centraliser la gestion des erreurs

Express 5 règle le principal défaut d'Express 4. Selon la page sur la gestion des erreurs, une route qui renvoie une promesse rejetée appelle automatiquement next(erreur) : une exception dans une fonction async atteint le gestionnaire sans try/catch. Ce gestionnaire prend quatre arguments et se déclare après les routes. Voici src/middleware/erreurs.js :

const messages = {
  400: 'Requête mal formée',
  413: 'Corps de requête trop volumineux',
  415: 'Encodage ou jeu de caractères non pris en charge',
};

export function routeInconnue(req, res) {
  res.status(404).json({ erreur: 'Ressource introuvable' });
}

export function gestionErreurs(err, req, res, next) {
  if (res.headersSent) return next(err);
  const statut = messages[err.status] ? err.status : 500;
  if (statut === 500) console.error(err);
  res.status(statut).json({ erreur: messages[statut] ?? 'Erreur interne du serveur' });
}
Enter fullscreen mode Exit fullscreen mode

Ces trois codes viennent du parseur JSON, dont la documentation attribue le statut 400 à un corps illisible, 413 à un corps trop gros et 415 à un encodage ou un jeu de caractères non pris en charge. Tout le reste devient un 500 générique, le détail restant dans les journaux du serveur : c'est la règle de l'OWASP, qui interdit d'envoyer au client une pile d'appels ou un indice technique. Si la réponse a déjà commencé (res.headersSent), Express recommande de déléguer à son gestionnaire par défaut.


Étape 7 : ajouter l'authentification JWT

L'API délivre à la connexion un jeton JWT signé, que le client renvoie dans l'en-tête Authorization: Bearer. Il faut d'abord stocker les mots de passe de façon sûre.

Hacher les mots de passe avec scrypt

La fiche de l'OWASP sur le stockage des mots de passe recommande Argon2id, puis scrypt avec un coût minimal de 2^17, une taille de bloc de 8 et un parallélisme de 1. Node.js fournit scrypt nativement. Voici src/motdepasse.js :

import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';

const scryptAsync = promisify(scrypt);
const PARAMS = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
const LONGUEUR = 64;

export async function hacher(motDePasse) {
  const sel = randomBytes(16);
  const cle = await scryptAsync(motDePasse, sel, LONGUEUR, PARAMS);
  return `${sel.toString('hex')}:${cle.toString('hex')}`;
}

export async function verifier(motDePasse, stocke) {
  const [sel, attendu] = stocke.split(':').map((h) => Buffer.from(h, 'hex'));
  const cle = await scryptAsync(motDePasse, sel, LONGUEUR, PARAMS);
  return timingSafeEqual(cle, attendu);
}
Enter fullscreen mode Exit fullscreen mode

maxmem n'est pas décoratif : la documentation du module crypto le fixe par défaut à 32 Mio et lève une erreur quand 128 × N × r le dépasse, soit 128 Mio avec les paramètres de l'OWASP. Elle recommande aussi un sel aléatoire d'au moins 16 octets. timingSafeEqual compare en temps constant.

Ce coût est voulu, mais il se paie : chaque inscription ou connexion mobilise environ 128 Mio de mémoire. De plus, crypto.scrypt() s'exécute dans le pool de threads de libuv, limité à 4 threads par défaut selon la documentation de UV_THREADPOOL_SIZE, un pool partagé avec les accès disque. D'où la limitation du nombre de tentatives ci-dessous, et une variable UV_THREADPOOL_SIZE à augmenter au lancement du processus si les connexions sont nombreuses.

Signer et vérifier les jetons

Le middleware src/middleware/auth.js crée les jetons et protège les routes :

import jwt from 'jsonwebtoken';

const optionsJwt = { issuer: 'api-taches', audience: 'api-taches' };

export function creerJeton(utilisateurId) {
  return jwt.sign({}, process.env.JWT_SECRET, {
    ...optionsJwt,
    algorithm: 'HS256',
    expiresIn: '15m',
    subject: String(utilisateurId),
  });
}

export function exigerAuth(req, res, next) {
  const [type, jeton] = (req.get('Authorization') ?? '').split(' ');
  let charge;
  try {
    if (type !== 'Bearer' || !jeton) throw new Error('jeton absent');
    charge = jwt.verify(jeton, process.env.JWT_SECRET, { ...optionsJwt, algorithms: ['HS256'] });
  } catch {
    res.set('WWW-Authenticate', 'Bearer');
    return res.status(401).json({ erreur: 'Authentification requise ou jeton expiré' });
  }
  req.utilisateurId = Number(charge.sub);
  next();
}
Enter fullscreen mode Exit fullscreen mode

Chaque option répond à l'OWASP. La fiche REST Security interdit les jetons non signés (alg: none) et demande de vérifier iss, aud et exp. La fiche JSON Web Token met en garde contre les algorithmes non fixés et les secrets faibles, qu'un attaquant peut deviner hors ligne. La RFC 7518 (section 3.2) impose une clé d'au moins 256 bits pour HS256, ce que vérifie server.js au démarrage. D'où algorithms: ['HS256'], le secret aléatoire de 32 octets et une durée de vie courte (15 minutes) qui limite l'usage d'un jeton volé.

Selon le README de jsonwebtoken, jwt.verify sans rappel renvoie la charge utile si signature, expiration, audience et émetteur sont valides, et lève une erreur sinon.

Limiter les tentatives

La page sécurité en production d'Express recommande de bloquer les tentatives d'authentification répétées, avec le paquet rate-limiter-flexible. Voici src/middleware/limiteur.js, qui accepte 5 tentatives par minute et par adresse IP :

import { RateLimiterMemory } from 'rate-limiter-flexible';

const limiteur = new RateLimiterMemory({ points: 5, duration: 60 });

export async function limiterAuth(req, res, next) {
  try {
    await limiteur.consume(req.ip);
  } catch (refus) {
    if (refus instanceof Error) throw refus;
    res.set('Retry-After', String(Math.ceil(refus.msBeforeNext / 1000)));
    return res.status(429).json({ erreur: 'Trop de tentatives, réessayez plus tard' });
  }
  next();
}
Enter fullscreen mode Exit fullscreen mode

Selon le README de rate-limiter-flexible, consume() rejette sa promesse avec un objet contenant msBeforeNext quand les points sont épuisés, du moins « s'il n'y a pas d'erreur » (notre traduction). Le code traite donc à part une éventuelle Error, renvoyée au gestionnaire d'erreurs, et utilise l'objet pour l'en-tête Retry-After du code 429. Derrière un proxy inverse, req.ip n'est la vraie adresse du client que si TRUST_PROXY est réglé (étape 2). Le compteur vit en mémoire : avec plusieurs instances, il faut un stockage partagé comme Redis, que le paquet prend en charge.

Les routes d'inscription et de connexion

Reste src/routes/auth.js :

import { Router } from 'express';
import { db } from '../db.js';
import { hacher, verifier } from '../motdepasse.js';
import { creerJeton } from '../middleware/auth.js';
import { limiterAuth } from '../middleware/limiteur.js';
import { exigerJson, valider } from '../middleware/validation.js';
import { schemaIdentifiants } from '../schemas.js';

export const routerAuth = Router();
routerAuth.use(limiterAuth);

routerAuth.post('/inscription', exigerJson, valider(schemaIdentifiants), async (req, res) => {
  const { email, motDePasse } = req.donnees;
  const hash = await hacher(motDePasse);
  // Aucun await entre la vérification et l'insertion : rien ne peut s'intercaler.
  if (db.prepare('SELECT id FROM utilisateurs WHERE email = ?').get(email)) {
    return res.status(409).json({ erreur: 'Un compte existe déjà pour cet e-mail' });
  }
  const { lastInsertRowid } = db
    .prepare('INSERT INTO utilisateurs (email, hash) VALUES (?, ?)')
    .run(email, hash);
  res.status(201).json({ id: Number(lastInsertRowid), email });
});

routerAuth.post('/connexion', exigerJson, valider(schemaIdentifiants), async (req, res) => {
  const { email, motDePasse } = req.donnees;
  const utilisateur = db.prepare('SELECT id, hash FROM utilisateurs WHERE email = ?').get(email);
  if (!utilisateur || !(await verifier(motDePasse, utilisateur.hash))) {
    res.set('WWW-Authenticate', 'Bearer');
    return res.status(401).json({ erreur: 'Identifiants invalides' });
  }
  res.json({ jeton: creerJeton(utilisateur.id) });
});
Enter fullscreen mode Exit fullscreen mode

L'ordre de l'inscription compte. Le hachage, asynchrone, a lieu avant la vérification de l'e-mail : la lecture et l'insertion s'enchaînent alors sans await, et comme node:sqlite est synchrone, deux inscriptions simultanées au même e-mail ne peuvent pas passer toutes deux la vérification dans un même processus. Avec plusieurs processus, la contrainte UNIQUE reste le dernier rempart et produirait un 500.

Essayer l'API à la main

Lancez npm run dev, puis enchaînez ces appels avec curl. Les réponses en commentaire sont celles que le code ci-dessus est censé produire ; nous ne les avons pas relevées en exécutant le code.

curl -i -X POST http://localhost:3000/api/v1/auth/inscription \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com","motDePasse":"une phrase de passe longue"}'
# Attendu : 201 Created, {"id":1,"email":"ada@example.com"}

curl -s -X POST http://localhost:3000/api/v1/auth/connexion \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com","motDePasse":"une phrase de passe longue"}'
# Attendu (corps) : {"jeton":"eyJ..."}

curl -i -X POST http://localhost:3000/api/v1/taches \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer COLLEZ_LE_JETON_ICI" \
  -d '{"titre":"Écrire les tests"}'
# Attendu : 201 Created, en-tête Location: /api/v1/taches/1,
# {"id":1,"titre":"Écrire les tests","terminee":false}
Enter fullscreen mode Exit fullscreen mode

Ce code a encore des limites assumées, à traiter avant la production :

  • La révocation. Un JWT reste valide jusqu'à son expiration. L'OWASP propose une liste de jetons révoqués pour la déconnexion anticipée.
  • Le blocage par compte. Express conseille de compter aussi les échecs par nom d'utilisateur, pas seulement par adresse IP ; notre limiteur ne le fait pas.
  • L'énumération des comptes. Le 409 de l'inscription et le temps de réponse de la connexion révèlent si un e-mail est inscrit.
  • Le navigateur. Pour un front-end web, une session en cookie httpOnly et secure évite d'exposer le jeton au JavaScript.

Pour vos utilisateurs finaux, les passkeys sont une alternative sérieuse aux mots de passe. Pour le contexte des menaces, voyez notre panorama des cyberattaques les plus fréquentes.


Étape 8 : tester l'API avec le lanceur de Node.js

Le lanceur de tests node:test est stable depuis Node.js 20 selon sa documentation. Avec fetch, intégré lui aussi, il suffit pour des tests d'intégration sans Jest ni Supertest. Voici test/api.test.js :

import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';

process.env.DB_PATH = ':memory:';
process.env.JWT_SECRET = 'cle-de-test-uniquement-0123456789abcdef0123456789';

const { app } = await import('../src/app.js');
let serveur;
let base;

before(async () => {
  serveur = app.listen(0);
  await once(serveur, 'listening');
  base = `http://127.0.0.1:${serveur.address().port}/api/v1`;
});

after(() => serveur.close());

function envoyer(chemin, corps, jeton) {
  const headers = { 'Content-Type': 'application/json' };
  if (jeton) headers.Authorization = `Bearer ${jeton}`;
  return fetch(`${base}${chemin}`, { method: 'POST', headers, body: JSON.stringify(corps) });
}

test('refuse la liste des tâches sans jeton', async () => {
  const reponse = await fetch(`${base}/taches`);
  assert.equal(reponse.status, 401);
});

test('inscrit, connecte puis crée une tâche', async () => {
  const identifiants = { email: 'ada@example.com', motDePasse: 'une phrase de passe longue' };
  assert.equal((await envoyer('/auth/inscription', identifiants)).status, 201);

  const { jeton } = await (await envoyer('/auth/connexion', identifiants)).json();
  const creation = await envoyer('/taches', { titre: 'Écrire les tests' }, jeton);
  assert.equal(creation.status, 201);
  assert.deepEqual(await creation.json(), { id: 1, titre: 'Écrire les tests', terminee: false });

  const invalide = await envoyer('/taches', { titre: '' }, jeton);
  assert.equal(invalide.status, 422);
});
Enter fullscreen mode Exit fullscreen mode

L'import dynamique de app.js est volontaire : les import statiques s'exécutent avant le reste du module, donc avant que DB_PATH ne désigne une base en mémoire. app.listen(0) choisit un port libre. Lancez le tout avec npm test. Postman ou Insomnia restent utiles pour explorer l'API à la main, mais ne remplacent pas des tests rejoués à chaque modification.


Étape 9 : préparer le déploiement

Une API REST Node.js prête pour la production repose sur quelques réglages, détaillés par la page performances en production d'Express :

  • NODE_ENV=production. Les messages d'erreur deviennent moins bavards. La page affirme que ce seul réglage triplerait les performances, une mesure de l'éditeur.
  • Un redémarrage automatique. Un gestionnaire de processus ou un service systemd relance l'application.
  • Un proxy inverse. Nginx ou HAProxy gère TLS, la compression et la répartition de charge.
  • Des journaux adaptés. console.log est synchrone vers un terminal ou un fichier : la page conseille une bibliothèque comme Pino.

Le HTTPS est obligatoire : l'OWASP exige que les services REST n'exposent que des points d'accès HTTPS, pour protéger mots de passe et jetons. Lancez aussi npm audit régulièrement, comme le rappelle la page sécurité d'Express.

Pour l'hébergement, le conteneur est la voie la plus portable : voyez notre guide Docker pour débutants, puis notre comparatif de l'hébergement cloud pour développeurs. Automatisez ensuite tests et mises en ligne avec notre guide pour automatiser ses déploiements en CI/CD.

Attention, la base SQLite est un fichier : sur une plateforme dont le disque est effacé à chaque déploiement, vos données disparaissent. Vérifiez la persistance du disque ou passez à PostgreSQL. Et sur plusieurs instances, les processus ne partagent pas leur mémoire, rappelle Express : voyez alors notre introduction à Kubernetes.


Ce que nous n'avons pas pu vérifier

Ce tutoriel est une synthèse documentaire. Restent incertains :

  • L'exécution du code. Il suit la documentation officielle de chaque outil au 24 octobre 2026, mais nous ne l'avons ni exécuté ni testé. Signalez-nous toute erreur.
  • Le passage de Node.js 26 en LTS. Le 28 octobre 2026 est la date du calendrier officiel ; elle peut glisser.
  • Le détail d'Express 5.3.0. Nous avons relevé la version et sa date, pas l'ensemble de ses changements.
  • Le facteur trois de NODE_ENV=production. C'est une affirmation d'Express, sans protocole publié sur la page.
  • Les réponses attendues des appels curl. Elles sont déduites du code, pas relevées lors d'une exécution.
  • Les réglages du limiteur. Cinq tentatives par minute est un exemple : ni Express ni l'OWASP ne fixent de seuil, et nous n'avons pas mesuré l'effet de scrypt sur un serveur réel.
  • Des chiffres d'usage d'Express plus récents. L'enquête Stack Overflow 2026 ne classe pas les frameworks web.
  • Les offres gratuites des hébergeurs. La version précédente en citait ; non revérifiées, elles ont été retirées.

Questions fréquentes

Quelle version de Node.js utiliser pour une API REST en 2026 ?

Une version LTS. Au 24 octobre 2026, c'est Node.js 24, en version 24.22.1, selon la page des versions de Node.js. Node.js 26 doit devenir LTS le 28 octobre 2026. Node.js 20 est en fin de vie : ne démarrez pas de projet dessus.

Faut-il passer d'Express 4 à Express 5 ?

Pour un nouveau projet, oui : Express 5 est la version servie par défaut sur npm et gère seule les erreurs des fonctions async. Pour un projet existant, le guide de migration liste les ruptures, surtout la syntaxe des chemins, et propose un outil de migration automatique.

Quelle différence entre REST et GraphQL ?

Une API REST expose une URL par ressource et s'appuie sur les verbes et codes de statut HTTP (voir le glossaire de MDN). GraphQL passe en général par un point d'accès unique où le client décrit les données voulues. REST reste plus simple pour débuter.

Faut-il utiliser TypeScript pour une API Express ?

Ce n'est pas obligatoire pour apprendre, mais utile dès que le projet grandit. TypeScript est utilisé par 43,8 % des répondants de l'enquête Stack Overflow 2026. Avec Zod, vous pouvez déjà dériver les types de vos schémas, ce qui facilite le passage.


À propos de l'auteur

Abdelilah Ourti est ingénieur IA et architecte logiciel. Il conçoit et déploie des applications web et des systèmes d'IA, et publie sur NeurauTech des guides et des comparatifs destinés aux développeurs francophones. Ses autres articles sont réunis sur sa page auteur.

Méthode. Synthèse documentaire fondée exclusivement sur des sources publiques consultées le 24 octobre 2026 : documentation de Node.js (versions 24 et 26) et son calendrier de versions, documentation et notes de version d'Express, documentations de Zod, jsonwebtoken, Helmet et body-parser, registre npm, fiches de l'OWASP, références HTTP de MDN, enquêtes Stack Overflow 2025 et 2026. Le code n'a pas été exécuté pour cet article. Aucun lien n'est affilié et aucune relation commerciale ne lie l'auteur aux éditeurs mentionnés.

Une erreur ou une commande périmée ? Écrivez à contact@neurautech.com : l'article sera corrigé et la date de mise à jour modifiée.

Dernière mise à jour : 24 octobre 2026. Première publication : 25 mai 2026.

Image : Markus Spiske (Unsplash License).

Top comments (0)