Presque toutes les applications modernes reposent sur une API REST : l’application mobile, le site React, l’outil interne… tous parlent au même serveur. Dans ce tutoriel, tu construis pas à pas une API REST avec Node.js, Express 5 et MySQL, prête pour la production : authentification JWT, validation des données avec Zod, gestion des erreurs centralisée, mots de passe hachés, limitation des tentatives et en-têtes de sécurité.

🟢 Node.js 22+⚡ Express 5🐬 MySQL / MariaDB🔐 JWT + bcrypt✅ Zod⏱️ 35 min

Tu sais déjà écrire un petit serveur Express qui renvoie « Hello World » ? Parfait. Le problème, c’est que l’écart entre ce serveur de démonstration et une vraie API est immense : qui a le droit de lire quelles données ? Que se passe-t-il si le client envoie n’importe quoi ? Comment éviter que la moindre erreur SQL fasse planter le serveur ou révèle ta base de données ?

On va répondre à toutes ces questions en construisant TaskAPI, une API de gestion de tâches : chaque utilisateur crée un compte, se connecte, puis gère ses propres tâches. Le projet est petit, mais il contient tous les réflexes d’une API Node.js professionnelle. Tout le code de ce tutoriel a été exécuté et testé : les réponses que tu verras viennent du vrai serveur.

Créer une API REST avec Node.js, Express et MySQL : architecture avec JWT, validation et gestion des erreurs
L’architecture de notre API REST Node.js : un client, une API Express protégée par ses middlewares, une base MySQL.
🎯
Ce que tu sauras faire à la fin

Structurer une API Express en couches, te connecter à MySQL avec un pool et des requêtes préparées, hacher les mots de passe avec bcrypt, émettre et vérifier des jetons JWT, valider chaque entrée avec Zod, renvoyer des codes HTTP cohérents (201, 401, 404, 409, 422…) et protéger ton API contre les attaques les plus courantes.

C’est quoi une API REST Node.js ?

Une API REST est une interface qui expose des ressources (ici : des utilisateurs et des tâches) via des URL, et qui utilise les verbes HTTP pour indiquer l’action. Les échanges se font en JSON, et le serveur ne garde aucune « session » en mémoire : chaque requête contient tout ce qu’il faut pour être traitée (c’est le principe stateless).

MéthodeURLActionCode de succès
POST/api/auth/registerCréer un compte201 Created
POST/api/auth/loginSe connecter, recevoir un jeton200 OK
GET/api/auth/meVoir son profil200 OK
GET/api/tasksLister ses tâches (filtre, pagination)200 OK
POST/api/tasksCréer une tâche201 Created
GET/api/tasks/:idLire une tâche200 OK
PATCH/api/tasks/:idModifier une partie d’une tâche200 OK
DELETE/api/tasks/:idSupprimer une tâche204 No Content
📚
Les codes HTTP, un vrai langage

Une bonne API répond toujours avec le code adapté : 401 quand on n’est pas connecté, 404 quand la ressource n’existe pas, 422 quand les données sont invalides… Si tu veux réviser, notre guide des codes HTTP les explique un par un.

Prérequis pour créer ton API REST Node.js

  • Node.js 22 ou plus récent (une version LTS). On utilise les modules ES, fetch natif et l’option --env-file, sans dotenv.
  • MySQL 8 ou MariaDB 10.6+ : celui de WampServer, de Docker ou de ton hébergeur convient.
  • Un outil pour tester les requêtes : curl, l’extension REST Client de VS Code ou Postman.
mkdir task-api && cd task-api
npm init -y
npm install express mysql2 jsonwebtoken bcryptjs zod helmet cors express-rate-limit
Terminal
PaquetRôle
express (v5)Le framework web : routes et middlewares
mysql2Le pilote MySQL, avec promesses et requêtes préparées
jsonwebtokenCréer et vérifier les jetons JWT
bcryptjsHacher les mots de passe
zodValider les données entrantes (et la configuration)
helmetAjouter les en-têtes de sécurité HTTP
corsAutoriser ton front-end à appeler l’API
express-rate-limitLimiter les tentatives de connexion
{
  "type": "module",
  "scripts": {
    "dev": "node --watch --env-file=.env src/server.js",
    "start": "node --env-file=.env src/server.js",
    "db:init": "node --env-file=.env src/db/init.js"
  },
  "engines": { "node": ">=22" }
}
package.json (extrait)

"type": "module" active la syntaxe import/export. node --watch redémarre le serveur à chaque modification (plus besoin de nodemon), et --env-file charge le fichier .env (plus besoin de dotenv).

L’architecture de l’API REST Node.js

Plutôt que de tout écrire dans un seul fichier, on sépare les responsabilités. Chaque dossier a un rôle précis, ce qui rend l’API facile à lire, à tester et à faire grandir.

task-api/ ├── .env Secrets et configuration (jamais versionné) ├── package.json └── src/ ├── server.js Démarre le serveur HTTP ├── app.js Assemble Express : middlewares, routes, erreurs ├── config/env.js Lit et valide les variables d’environnement ├── db/ │ ├── pool.js Pool de connexions MySQL │ ├── schema.sql Tables users et tasks │ └── init.js Crée les tables ├── middlewares/ │ ├── auth.js Vérifie le jeton JWT │ ├── validate.js Valide body / params / query avec Zod │ └── errorHandler.js 404 + gestion centralisée des erreurs ├── schemas/ Règles de validation Zod ├── controllers/ La logique : requêtes SQL et réponses ├── routes/ Associe URL → middlewares → contrôleur └── utils/AppError.js Erreur métier avec code HTTP
Trajet d'une requête dans une API REST Express : sécurité, routeur, JWT, validation, contrôleur
Le trajet d’une requête dans notre API REST Node.js : chaque middleware peut répondre, passer la main ou lever une erreur.

Le principe clé d’Express, c’est la chaîne de middlewares : une requête traverse une suite de fonctions (req, res, next). Chacune peut répondre au client, appeler next() pour passer à la suivante, ou lever une erreur qui saute directement au gestionnaire d’erreurs.

Étape 1 : la configuration validée

Les secrets (mot de passe MySQL, clé JWT) ne doivent jamais apparaître dans le code. On les place dans un fichier .env, ajouté au .gitignore :

PORT=3000
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=taskapi
DB_PASSWORD=motdepasse-fort
DB_NAME=taskapi
JWT_SECRET=remplace-moi-par-une-longue-chaine-aleatoire
JWT_EXPIRES_IN=1h
CORS_ORIGIN=http://localhost:5173
.env

Puis on les valide au démarrage avec Zod. Si une variable manque ou si la clé JWT est trop courte, l’API refuse de démarrer avec un message clair, plutôt que de planter mystérieusement à la première requête :

import { z } from 'zod';

// On valide les variables d'environnement au démarrage : si une manque, l'API refuse de démarrer.
const schema = z.object({
  PORT: z.coerce.number().default(3000),
  DB_HOST: z.string().default('127.0.0.1'),
  DB_PORT: z.coerce.number().default(3306),
  DB_USER: z.string(),
  DB_PASSWORD: z.string(),
  DB_NAME: z.string(),
  JWT_SECRET: z.string().min(32, 'JWT_SECRET doit contenir au moins 32 caractères'),
  JWT_EXPIRES_IN: z.string().default('1h'),
  CORS_ORIGIN: z.string().default('*'),
});

const resultat = schema.safeParse(process.env);
if (!resultat.success) {
  console.error('❌ Configuration invalide :', z.flattenError(resultat.error).fieldErrors);
  process.exit(1);
}

export const env = resultat.data;
src/config/env.js
🔑
Générer une vraie clé secrète

Une clé JWT doit être longue et aléatoire. Génère-la avec : node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"

Étape 2 : la base de données MySQL

Crée la base et un utilisateur dédié (évite d’utiliser root pour une application) :

CREATE DATABASE taskapi CHARACTER SET utf8mb4;
CREATE USER 'taskapi'@'localhost' IDENTIFIED BY 'motdepasse-fort';
GRANT ALL PRIVILEGES ON taskapi.* TO 'taskapi'@'localhost';
Console MySQL / phpMyAdmin

Puis les deux tables. Chaque tâche appartient à un utilisateur grâce à une clé étrangère : si l’utilisateur est supprimé, ses tâches le sont aussi (ON DELETE CASCADE).

CREATE TABLE IF NOT EXISTS users (
  id            INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  email         VARCHAR(255) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  name          VARCHAR(100) NOT NULL,
  created_at    TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE IF NOT EXISTS tasks (
  id          INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id     INT UNSIGNED NOT NULL,
  title       VARCHAR(150) NOT NULL,
  description TEXT NULL,
  status      ENUM('a_faire', 'en_cours', 'terminee') NOT NULL DEFAULT 'a_faire',
  due_date    DATE NULL,
  created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  CONSTRAINT fk_tasks_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  INDEX idx_tasks_user_status (user_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
src/db/schema.sql

L’index composite (user_id, status) n’est pas là par hasard : toutes nos requêtes filtrent par utilisateur, et souvent par statut. Si ce choix t’intrigue, notre article Optimiser une requête MySQL lente explique la règle du préfixe gauche.

import { readFile } from 'node:fs/promises';
import mysql from 'mysql2/promise';
import { env } from '../config/env.js';

const sql = await readFile(new URL('./schema.sql', import.meta.url), 'utf8');
const connexion = await mysql.createConnection({
  host: env.DB_HOST, port: env.DB_PORT, user: env.DB_USER,
  password: env.DB_PASSWORD, database: env.DB_NAME, multipleStatements: true,
});
await connexion.query(sql);
await connexion.end();
console.log('✅ Tables créées');
src/db/init.js

Lance npm run db:init. Ensuite, l’application utilise un pool de connexions : au lieu d’ouvrir une connexion à chaque requête (lent), on en garde quelques-unes ouvertes et on les réutilise.

import mysql from 'mysql2/promise';
import { env } from '../config/env.js';

// Un « pool » réutilise les connexions au lieu d'en ouvrir une par requête.
export const pool = mysql.createPool({
  host: env.DB_HOST,
  port: env.DB_PORT,
  user: env.DB_USER,
  password: env.DB_PASSWORD,
  database: env.DB_NAME,
  connectionLimit: 10,
  namedPlaceholders: true,
  dateStrings: true,          // renvoie les dates telles quelles ("2026-09-30"), sans décalage horaire
});
src/db/pool.js
🕐
Le piège des dates

Sans dateStrings: true, mysql2 convertit une colonne DATE en objet JavaScript dans le fuseau horaire du serveur : « 2026-09-30 » peut devenir « 2026-09-29T22:00:00.000Z ». Nous avons rencontré ce bug en testant ce tutoriel.

Étape 3 : la gestion des erreurs de l’API REST Node.js

On prépare la gestion des erreurs avant le reste, car tout le code va s’appuyer dessus. L’idée : n’importe où dans l’application, on lance une erreur avec un code HTTP, et un seul middleware se charge de construire la réponse.

// Une erreur « prévue » : on connaît son code HTTP et son message est affichable au client.
export class AppError extends Error {
  constructor(statusCode, message, details) {
    super(message);
    this.statusCode = statusCode;
    this.details = details;
  }
}
src/utils/AppError.js
import { AppError } from '../utils/AppError.js';

export function notFound(req, res, next) {
  next(new AppError(404, `Route introuvable : ${req.method} ${req.originalUrl}`));
}

// Les middlewares d'erreur ont 4 paramètres : c'est ainsi qu'Express les reconnaît.
export function errorHandler(err, req, res, next) {
  // JSON mal formé envoyé par le client
  if (err.type === 'entity.parse.failed') {
    return res.status(400).json({ error: { message: 'JSON invalide' } });
  }
  // Doublon MySQL (ex. : e-mail déjà utilisé)
  if (err.code === 'ER_DUP_ENTRY') {
    return res.status(409).json({ error: { message: 'Cette ressource existe déjà' } });
  }
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      error: { message: err.message, ...(err.details && { details: err.details }) },
    });
  }

  // Erreur inattendue : on la journalise, mais on ne divulgue rien au client.
  console.error(err);
  res.status(500).json({ error: { message: 'Erreur interne du serveur' } });
}
src/middlewares/errorHandler.js
⚡
La grande nouveauté d’Express 5

Avec Express 4, une erreur levée dans une fonction async n’était pas attrapée : il fallait entourer chaque contrôleur d’un try/catch ou utiliser un utilitaire asyncHandler. Express 5 transmet automatiquement les promesses rejetées au gestionnaire d’erreurs. Nos contrôleurs peuvent donc lancer des erreurs librement, sans aucun try/catch.

Remarque la dernière règle : une erreur imprévue (bug, base de données injoignable) renvoie un message générique. On ne renvoie jamais la pile d’appels ou le message SQL au client : ce serait offrir une carte de ton serveur à un attaquant.

Étape 4 : valider chaque entrée de l’API REST Node.js avec Zod

Règle d’or : ne jamais faire confiance aux données du client. Un champ manquant, un type inattendu, une chaîne de 10 Mo… Zod décrit la forme attendue des données et renvoie des erreurs précises, champ par champ.

import { z } from 'zod';

export const registerSchema = z.object({
  name: z.string().trim().min(2, 'Au moins 2 caractères').max(100),
  email: z.email('E-mail invalide').toLowerCase(),
  password: z.string().min(8, 'Au moins 8 caractères').max(72),
});

export const loginSchema = z.object({
  email: z.email('E-mail invalide').toLowerCase(),
  password: z.string().min(1, 'Mot de passe requis'),
});
src/schemas/auth.schema.js
import { z } from 'zod';

const statut = z.enum(['a_faire', 'en_cours', 'terminee'], { error: 'Statut inconnu' });

export const idParams = z.object({
  id: z.coerce.number({ error: 'Identifiant invalide' }).int().positive(),
});

// Les champs communs, SANS valeur par défaut
const champsTache = {
  title: z.string().trim().min(1, 'Titre requis').max(150),
  description: z.string().trim().max(5000).nullable(),
  status: statut,
  due_date: z.iso.date('Date au format AAAA-MM-JJ').nullable(),
};

// Création : titre obligatoire, statut « a_faire » par défaut
export const createTaskSchema = z.object({
  ...champsTache,
  description: champsTache.description.optional(),
  status: statut.default('a_faire'),
  due_date: champsTache.due_date.optional(),
});

// Mise à jour partielle (PATCH) : tout est facultatif, mais au moins un champ est requis.
// Pas de valeur par défaut ici, sinon un PATCH { title } remettrait le statut à « a_faire » !
export const updateTaskSchema = z.object(champsTache).partial().refine(
  (data) => Object.keys(data).length > 0,
  { message: 'Aucun champ à modifier' },
);

export const listQuery = z.object({
  status: statut.optional(),
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});
src/schemas/task.schema.js
🐛
Un bug que nous avons corrigé en testant

La première version dérivait le schéma de mise à jour du schéma de création avec .partial(). Problème : la valeur par défaut du statut était conservée, et un simple PATCH { "title": "…" } remettait silencieusement la tâche à « a_faire ». D’où les deux schémas distincts ci-dessus. Moralité : teste toujours tes PATCH.

Un middleware générique applique ces schémas aux trois sources de données d’une requête : le corps (body), les paramètres d’URL (params) et la chaîne de requête (query) :

import { z } from 'zod';
import { AppError } from '../utils/AppError.js';

// Utilisation : validate({ body: schema, params: schema, query: schema })
export const validate = (schemas) => (req, res, next) => {
  for (const cle of ['params', 'query', 'body']) {
    if (!schemas[cle]) continue;

    const resultat = schemas[cle].safeParse(req[cle] ?? {});
    if (!resultat.success) {
      const { formErrors, fieldErrors } = z.flattenError(resultat.error);
      const details = formErrors.length ? { ...fieldErrors, general: formErrors } : fieldErrors;
      throw new AppError(422, 'Données invalides', details);
    }
    // En Express 5, req.query est en lecture seule : on range les données validées dans req.valid.
    req.valid = { ...req.valid, [cle]: resultat.data };
  }
  next();
};
src/middlewares/validate.js

Les contrôleurs n’utiliseront que req.valid : des données vérifiées, nettoyées et typées (les nombres sont de vrais nombres, les e-mails sont en minuscules, les champs inconnus comme user_id sont ignorés).

Étape 5 : l’authentification JWT de l’API REST Node.js

Comment fonctionne un JWT ?

Un JWT (JSON Web Token) est une chaîne en trois parties séparées par des points : en-tête.contenu.signature. Le contenu (par exemple l’identifiant de l’utilisateur) est simplement encodé en Base64, pas chiffré : n’importe qui peut le lire. Ce qui le rend fiable, c’est la signature, calculée avec ta clé secrète : si quelqu’un modifie le contenu, la signature ne correspond plus et le serveur rejette le jeton.

Authentification JWT dans une API REST Node.js : login, jeton, requête Bearer, vérification
Le flux JWT de notre API REST Node.js : le client se connecte une fois, puis présente son jeton à chaque requête.
🔓
Ne mets jamais de données sensibles dans un JWT

Mot de passe, numéro de carte, adresse : jamais. Uniquement un identifiant et éventuellement un rôle. Colle n’importe quel jeton dans jwt.io et tu verras son contenu en clair.

L’inscription et la connexion

import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { pool } from '../db/pool.js';
import { env } from '../config/env.js';
import { AppError } from '../utils/AppError.js';

const creerJeton = (user) =>
  jwt.sign({ sub: String(user.id), email: user.email }, env.JWT_SECRET, { expiresIn: env.JWT_EXPIRES_IN });

export async function register(req, res) {
  const { name, email, password } = req.valid.body;
  const hash = await bcrypt.hash(password, 12);

  const [resultat] = await pool.execute(
    'INSERT INTO users (name, email, password_hash) VALUES (:name, :email, :hash)',
    { name, email, hash },
  );

  const user = { id: resultat.insertId, name, email };
  res.status(201).json({ user, token: creerJeton(user) });
}

export async function login(req, res) {
  const { email, password } = req.valid.body;
  const [lignes] = await pool.execute('SELECT * FROM users WHERE email = :email', { email });
  const user = lignes[0];

  // Même message dans les deux cas : on ne révèle pas si l'e-mail existe.
  const valide = user && (await bcrypt.compare(password, user.password_hash));
  if (!valide) throw new AppError(401, 'Identifiants incorrects');

  res.json({ user: { id: user.id, name: user.name, email: user.email }, token: creerJeton(user) });
}

export async function me(req, res) {
  const [lignes] = await pool.execute(
    'SELECT id, name, email, created_at FROM users WHERE id = :id', { id: req.user.id },
  );
  if (!lignes[0]) throw new AppError(404, 'Utilisateur introuvable');
  res.json({ user: lignes[0] });
}
src/controllers/auth.controller.js
  • bcrypt avec un coût de 12 : le hachage prend volontairement quelques dizaines de millisecondes, ce qui rend les attaques par dictionnaire extrêmement lentes. On ne stocke jamais le mot de passe lui-même.
  • Pas de vérification « e-mail déjà pris » avant l’INSERT : la contrainte UNIQUE de MySQL s’en charge et notre gestionnaire transforme l’erreur ER_DUP_ENTRY en 409. C’est plus simple et sans risque de concurrence.
  • Le même message d’erreur pour un e-mail inconnu ou un mauvais mot de passe : un attaquant ne peut pas deviner quels comptes existent.
  • Les requêtes préparées (:email) rendent l’injection SQL impossible : les valeurs ne sont jamais concaténées dans la requête.

Le middleware qui protège les routes

import jwt from 'jsonwebtoken';
import { env } from '../config/env.js';
import { AppError } from '../utils/AppError.js';

export function requireAuth(req, res, next) {
  const entete = req.headers.authorization ?? '';
  const [type, token] = entete.split(' ');

  if (type !== 'Bearer' || !token) {
    throw new AppError(401, 'Authentification requise');
  }

  try {
    const payload = jwt.verify(token, env.JWT_SECRET);
    req.user = { id: Number(payload.sub), email: payload.email };
    next();
  } catch {
    throw new AppError(401, 'Jeton invalide ou expiré');
  }
}
src/middlewares/auth.js

jwt.verify() contrôle à la fois la signature et la date d’expiration. Si tout est bon, on attache l’utilisateur à la requête (req.user) : les contrôleurs sauront qui fait la demande.

Étape 6 : le CRUD des tâches dans l’API REST Node.js

Le cœur de l’API. Point essentiel de sécurité : chaque requête SQL filtre par user_id. Sans cela, un utilisateur connecté pourrait lire ou supprimer la tâche d’un autre en changeant simplement l’identifiant dans l’URL (une faille appelée IDOR, très fréquente).

import { pool } from '../db/pool.js';
import { AppError } from '../utils/AppError.js';

const COLONNES = ['title', 'description', 'status', 'due_date'];

async function trouverTache(id, userId) {
  const [lignes] = await pool.execute(
    'SELECT * FROM tasks WHERE id = :id AND user_id = :userId', { id, userId },
  );
  if (!lignes[0]) throw new AppError(404, 'Tâche introuvable');
  return lignes[0];
}

export async function list(req, res) {
  const { status, page, limit } = req.valid.query;
  const filtres = { userId: req.user.id, limit, offset: (page - 1) * limit };
  const condition = status ? 'AND status = :status' : '';
  if (status) filtres.status = status;

  const [taches] = await pool.query(
    `SELECT * FROM tasks WHERE user_id = :userId ${condition}
     ORDER BY created_at DESC LIMIT :limit OFFSET :offset`, filtres,
  );
  const [[{ total }]] = await pool.query(
    `SELECT COUNT(*) AS total FROM tasks WHERE user_id = :userId ${condition}`, filtres,
  );
  res.json({ data: taches, meta: { page, limit, total } });
}

export async function getOne(req, res) {
  res.json({ data: await trouverTache(req.valid.params.id, req.user.id) });
}

export async function create(req, res) {
  const { title, description = null, status, due_date = null } = req.valid.body;
  const [resultat] = await pool.execute(
    `INSERT INTO tasks (user_id, title, description, status, due_date)
     VALUES (:userId, :title, :description, :status, :due_date)`,
    { userId: req.user.id, title, description, status, due_date },
  );
  const tache = await trouverTache(resultat.insertId, req.user.id);
  res.status(201).location(`/api/tasks/${tache.id}`).json({ data: tache });
}

export async function update(req, res) {
  const { id } = req.valid.params;
  await trouverTache(id, req.user.id);                      // 404 si elle n'est pas à lui

  // On ne construit la requête qu'avec des colonnes autorisées (liste blanche).
  const champs = Object.keys(req.valid.body).filter((c) => COLONNES.includes(c));
  const set = champs.map((c) => `${c} = :${c}`).join(', ');
  await pool.execute(`UPDATE tasks SET ${set} WHERE id = :id`, { ...req.valid.body, id });

  res.json({ data: await trouverTache(id, req.user.id) });
}

export async function remove(req, res) {
  const [resultat] = await pool.execute(
    'DELETE FROM tasks WHERE id = :id AND user_id = :userId',
    { id: req.valid.params.id, userId: req.user.id },
  );
  if (resultat.affectedRows === 0) throw new AppError(404, 'Tâche introuvable');
  res.status(204).end();
}
src/controllers/task.controller.js

Trois détails qui font la différence :

  • 404 plutôt que 403 quand la tâche appartient à quelqu’un d’autre : on ne confirme même pas son existence.
  • Une liste blanche de colonnes pour le UPDATE dynamique : seuls des noms de colonnes connus sont insérés dans le SQL, les valeurs restent des paramètres préparés.
  • pool.query pour la liste : LIMIT et OFFSET doivent être de vrais entiers, ce que l’API de requêtes préparées de MySQL gère mal ; nos valeurs viennent de Zod et sont garanties numériques. Pour de très grosses tables, préfère la pagination par curseur.

Étape 7 : les routes et l’assemblage

import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import * as auth from '../controllers/auth.controller.js';
import { validate } from '../middlewares/validate.js';
import { requireAuth } from '../middlewares/auth.js';
import { registerSchema, loginSchema } from '../schemas/auth.schema.js';

const router = Router();

// 10 tentatives par quart d'heure et par IP : on freine les attaques par force brute.
const limiteur = rateLimit({ windowMs: 15 * 60 * 1000, limit: 10, standardHeaders: 'draft-8', legacyHeaders: false });

router.post('/register', limiteur, validate({ body: registerSchema }), auth.register);
router.post('/login', limiteur, validate({ body: loginSchema }), auth.login);
router.get('/me', requireAuth, auth.me);

export default router;
src/routes/auth.routes.js
import { Router } from 'express';
import * as tasks from '../controllers/task.controller.js';
import { validate } from '../middlewares/validate.js';
import { requireAuth } from '../middlewares/auth.js';
import { idParams, createTaskSchema, updateTaskSchema, listQuery } from '../schemas/task.schema.js';

const router = Router();
router.use(requireAuth);                 // toutes les routes de tâches sont protégées

router.get('/', validate({ query: listQuery }), tasks.list);
router.post('/', validate({ body: createTaskSchema }), tasks.create);
router.get('/:id', validate({ params: idParams }), tasks.getOne);
router.patch('/:id', validate({ params: idParams, body: updateTaskSchema }), tasks.update);
router.delete('/:id', validate({ params: idParams }), tasks.remove);

export default router;
src/routes/task.routes.js

On lit chaque route comme une phrase : « pour un PATCH sur /:id, vérifie le jeton, valide l’identifiant et le corps, puis appelle update ». Il ne reste qu’à tout assembler :

import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { env } from './config/env.js';
import authRoutes from './routes/auth.routes.js';
import taskRoutes from './routes/task.routes.js';
import { notFound, errorHandler } from './middlewares/errorHandler.js';

export const app = express();

app.use(helmet());                                   // en-têtes de sécurité HTTP
app.use(cors({ origin: env.CORS_ORIGIN }));          // qui a le droit d'appeler l'API
app.use(express.json({ limit: '100kb' }));           // lecture du corps JSON

app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
app.use('/api/auth', authRoutes);
app.use('/api/tasks', taskRoutes);

app.use(notFound);                                   // 404 pour tout le reste
app.use(errorHandler);                               // toujours en dernier
src/app.js
import { app } from './app.js';
import { env } from './config/env.js';
import { pool } from './db/pool.js';

const serveur = app.listen(env.PORT, () => {
  console.log(`🚀 API démarrée sur http://localhost:${env.PORT}`);
});

// Arrêt propre : on termine les requêtes en cours et on ferme les connexions MySQL.
const arreter = () => serveur.close(async () => { await pool.end(); process.exit(0); });
process.on('SIGINT', arreter);
process.on('SIGTERM', arreter);
src/server.js

Séparer app.js (la configuration Express) de server.js (le démarrage) a un gros avantage : tu pourras importer app dans des tests automatisés (avec Supertest, par exemple) sans ouvrir de port réseau.

Étape 8 : tester l’API REST Node.js

Lance npm run dev, puis teste chaque route. Voici la session réelle enregistrée pendant la rédaction de ce tutoriel :

Tests de l'API REST Node.js Express avec curl : inscription, création de tâche, validation, authentification
Les tests de l’API REST Node.js : inscription, création, données invalides (422), accès sans jeton (401) et doublon (409).

Avec l’extension REST Client de VS Code, tu peux garder tous ces tests dans un fichier versionné avec ton projet :

@base = http://localhost:3000/api
@token = colle-ici-le-jeton-recu-au-login

### Inscription
POST {{base}}/auth/register
Content-Type: application/json

{ "name": "Alice", "email": "[email protected]", "password": "secret123" }

### Connexion
POST {{base}}/auth/login
Content-Type: application/json

{ "email": "[email protected]", "password": "secret123" }

### Créer une tâche
POST {{base}}/tasks
Authorization: Bearer {{token}}
Content-Type: application/json

{ "title": "Écrire le tutoriel", "due_date": "2026-09-30" }

### Mes tâches en cours, page 1
GET {{base}}/tasks?status=en_cours&page=1&limit=10
Authorization: Bearer {{token}}

### Terminer la tâche 1
PATCH {{base}}/tasks/1
Authorization: Bearer {{token}}
Content-Type: application/json

{ "status": "terminee" }
api.http
SituationCode renvoyéRéponse
Données invalides422{"error":{"message":"Données invalides","details":{"status":["Statut inconnu"]}}}
Pas de jeton401{"error":{"message":"Authentification requise"}}
E-mail déjà inscrit409{"error":{"message":"Cette ressource existe déjà"}}
Tâche d’un autre utilisateur404{"error":{"message":"Tâche introuvable"}}
11ᵉ tentative de connexion en 15 min429Too Many Requests
JSON mal formé400{"error":{"message":"JSON invalide"}}

Checklist sécurité de ton API REST Node.js avant la production

  1. HTTPS obligatoire : un JWT intercepté en clair donne accès au compte.
  2. Clé JWT longue et secrète, différente pour chaque environnement, jamais commitée.
  3. Durée de vie courte des jetons (15 min à 1 h), avec un mécanisme de refresh token si l’utilisateur doit rester connecté longtemps.
  4. CORS restreint au domaine de ton front-end, jamais * en production.
  5. Limitation des tentatives sur les routes sensibles (déjà en place).
  6. Utilisateur MySQL dédié, avec les seuls droits nécessaires.
  7. Journaux d’erreurs côté serveur (Pino, Winston) sans données personnelles, et surveillance de la disponibilité.
🖥️
Et pour héberger l’API ?

Un VPS avec Node.js, PM2 (pour relancer l’API en cas de crash) et Nginx en reverse proxy est la solution classique. Docker simplifie encore le déploiement : notre guide Docker en local t’aidera à démarrer.

Aller plus loin

  • Tests automatisés avec le test runner intégré de Node.js (node --test) et Supertest, en important app.js.
  • Rôles et permissions : ajoute une colonne role et un middleware requireRole('admin').
  • Refresh tokens stockés en base (et révocables) pour garder l’utilisateur connecté sans allonger la durée du JWT.
  • Documentation OpenAPI / Swagger générée à partir des schémas Zod.
  • Un front-end : branche une application React ou mobile sur cette API, comme dans notre projet MediaCloud.
  • Automatiser : la même stack Node.js permet de créer un bot Discord ou Telegram qui surveille un site.

FAQ : API REST Node.js avec Express et MySQL

Faut-il utiliser Express 4 ou Express 5 ?

Express 5 est désormais la version stable installée par défaut. Son principal avantage est la gestion native des erreurs dans les fonctions async : plus besoin de try/catch dans chaque contrôleur.

Où stocker le JWT côté client ?

Pour une application web, un cookie HttpOnly, Secure et SameSite est le plus sûr car il est inaccessible au JavaScript. Le localStorage est plus simple mais exposé en cas de faille XSS. Pour une application mobile, utilise le stockage sécurisé du système.

Pourquoi utiliser bcrypt plutôt que SHA-256 pour les mots de passe ?

SHA-256 est conçu pour être rapide, ce qui permet de tester des milliards de mots de passe par seconde. bcrypt est volontairement lent et intègre un sel unique, ce qui rend les attaques par dictionnaire beaucoup plus coûteuses.

mysql2 ou un ORM comme Prisma ou Sequelize ?

mysql2 avec des requêtes préparées est idéal pour apprendre et garder le contrôle du SQL. Un ORM apporte les migrations, le typage et moins de code répétitif sur les gros projets. Les deux sont de bons choix.

Comment éviter les injections SQL avec Node.js ?

En utilisant toujours des requêtes préparées avec des paramètres, comme pool.execute avec :nom ou ?, et jamais en concaténant des valeurs saisies par l’utilisateur dans une requête SQL.

Conclusion

Tu as maintenant une API REST avec Node.js, Express et MySQL solide : une architecture claire en couches, une authentification JWT avec mots de passe hachés, une validation systématique des entrées, une gestion des erreurs centralisée qui renvoie toujours le bon code HTTP, et les protections indispensables (Helmet, CORS, limitation des tentatives, requêtes préparées, contrôle de propriété des données).

C’est une base que tu peux réutiliser pour n’importe quel projet : remplace les tâches par des produits, des réservations ou des articles, et l’ossature reste la même. Pour aller plus loin, la documentation officielle d’Express et le guide JWT.io sont d’excellentes références.

Comments

No comments yet. Why don’t you start the discussion?

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *