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é.
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.

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éthode | URL | Action | Code de succès |
|---|---|---|---|
POST | /api/auth/register | Créer un compte | 201 Created |
POST | /api/auth/login | Se connecter, recevoir un jeton | 200 OK |
GET | /api/auth/me | Voir son profil | 200 OK |
GET | /api/tasks | Lister ses tâches (filtre, pagination) | 200 OK |
POST | /api/tasks | Créer une tâche | 201 Created |
GET | /api/tasks/:id | Lire une tâche | 200 OK |
PATCH | /api/tasks/:id | Modifier une partie d’une tâche | 200 OK |
DELETE | /api/tasks/:id | Supprimer une tâche | 204 No Content |
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,
fetchnatif 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-limitTerminal| Paquet | Rôle |
|---|---|
express (v5) | Le framework web : routes et middlewares |
mysql2 | Le pilote MySQL, avec promesses et requêtes préparées |
jsonwebtoken | Créer et vérifier les jetons JWT |
bcryptjs | Hacher les mots de passe |
zod | Valider les données entrantes (et la configuration) |
helmet | Ajouter les en-têtes de sécurité HTTP |
cors | Autoriser ton front-end à appeler l’API |
express-rate-limit | Limiter 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.

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.envPuis 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.jsUne 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 / phpMyAdminPuis 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.sqlL’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.jsLance 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.jsSans 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.jsimport { 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.jsAvec 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.jsimport { 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.jsLa 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.jsLes 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.

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
UNIQUEde MySQL s’en charge et notre gestionnaire transforme l’erreurER_DUP_ENTRYen 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.jsjwt.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.jsTrois 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
UPDATEdynamique : seuls des noms de colonnes connus sont insérés dans le SQL, les valeurs restent des paramètres préparés. pool.querypour la liste :LIMITetOFFSETdoivent ê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.jsimport { 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.jsOn 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 derniersrc/app.jsimport { 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.jsSé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 :

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| Situation | Code renvoyé | Réponse |
|---|---|---|
| Données invalides | 422 | {"error":{"message":"Données invalides","details":{"status":["Statut inconnu"]}}} |
| Pas de jeton | 401 | {"error":{"message":"Authentification requise"}} |
| E-mail déjà inscrit | 409 | {"error":{"message":"Cette ressource existe déjà"}} |
| Tâche d’un autre utilisateur | 404 | {"error":{"message":"Tâche introuvable"}} |
| 11ᵉ tentative de connexion en 15 min | 429 | Too Many Requests |
| JSON mal formé | 400 | {"error":{"message":"JSON invalide"}} |
Checklist sécurité de ton API REST Node.js avant la production
- HTTPS obligatoire : un JWT intercepté en clair donne accès au compte.
- Clé JWT longue et secrète, différente pour chaque environnement, jamais commitée.
- 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.
- CORS restreint au domaine de ton front-end, jamais
*en production. - Limitation des tentatives sur les routes sensibles (déjà en place).
- Utilisateur MySQL dédié, avec les seuls droits nécessaires.
- Journaux d’erreurs côté serveur (Pino, Winston) sans données personnelles, et surveillance de la disponibilité.
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 importantapp.js. - Rôles et permissions : ajoute une colonne
roleet un middlewarerequireRole('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.

