Photo de profil, pièce jointe, import de CSV… L’upload de fichiers avec Node.js est une fonctionnalité qu’on retrouve dans presque toutes les applications. Dans ce guide, tu construis pas à pas une API d’envoi de fichiers avec Express et Multer : stockage sur disque, limites de taille, filtrage par type, envois multiples, barre de progression et, surtout, toutes les protections de sécurité indispensables.

🟩 Node.js🚂 Express 5📦 Multer 2.2🛡️ Sécurisé⏱️ 25 min de lecture

L’upload de fichiers Node.js paraît simple : un formulaire, un bouton « Envoyer », et le fichier arrive sur le serveur. En réalité, c’est l’une des fonctionnalités les plus sensibles d’une application web. Un fichier trop gros peut saturer ton disque, un faux fichier image peut contenir un script, et un nom de fichier bien choisi peut écraser un fichier existant.

Upload de fichiers Node.js : Upload de fichiers avec Node.js et Multer : guide complet
Upload de fichiers avec Node.js et Multer : guide complet

Dans ce tutoriel, on utilise Multer, le middleware de référence pour gérer les formulaires multipart/form-data avec Express. Tu vas partir d’un dossier vide et arriver à une API complète et sécurisée, que tu pourras réutiliser dans tes projets.

Upload de fichiers Node.js : le parcours d’un fichier envoyé et ses contrôles
Le parcours d’un fichier envoyé : du formulaire au disque, en passant par les contrôles de Multer.
🎯
Ce que tu vas construire
  • Une API Express qui reçoit un ou plusieurs fichiers.
  • Un stockage sur disque avec des noms de fichiers uniques et sûrs.
  • Des limites de taille et de nombre, un filtre par type de fichier.
  • Une vérification du vrai contenu du fichier (et pas seulement de son extension).
  • Une page HTML avec glisser-déposer et barre de progression.

Prérequis

OutilVersionPourquoi
Node.js20 LTS ou plusExécuter le serveur
npmFourni avec Node.jsInstaller Express et Multer
Un éditeurVS Code, WebStorm…Écrire le code
Postman ou curlFacultatifTester l’API sans interface

Si tu n’as jamais créé de serveur Express, pas d’inquiétude : chaque fichier est expliqué ligne par ligne.

Comment fonctionne l’upload de fichiers avec Node.js ?

Quand un formulaire HTML envoie un fichier, le navigateur n’utilise pas l’encodage habituel (application/x-www-form-urlencoded) mais le format multipart/form-data. La requête est découpée en plusieurs « parties » : une pour chaque champ texte et une pour chaque fichier, séparées par une frontière (boundary).

Express ne sait pas lire ce format nativement : express.json() et express.urlencoded() l’ignorent. C’est le rôle de Multer :

  1. il lit le flux de la requête au fur et à mesure (sans tout charger en mémoire) ;
  2. il enregistre chaque fichier selon le stockage que tu as choisi (disque ou mémoire) ;
  3. il ajoute les informations du fichier dans req.file (ou req.files) et les champs texte dans req.body.
💡
Pourquoi Multer plutôt qu’un autre paquet ?

Multer est maintenu par l’équipe d’Express, il s’intègre comme un middleware classique et s’appuie sur busboy, un analyseur multipart rapide qui traite les données en flux. Des alternatives existent (formidable, busboy directement), mais Multer reste le choix le plus simple et le plus documenté pour une application Express.

Étape 1 : créer le projet

Ouvre un terminal et crée le dossier du projet :

mkdir upload-api && cd upload-api
npm init -y
npm install express multer@^2.2.0 file-type
mkdir uploads public
Bash

Dans package.json, ajoute "type": "module" pour utiliser la syntaxe import, ainsi qu’un script de démarrage :

{
  "name": "upload-api",
  "type": "module",
  "scripts": {
    "dev": "node --watch server.js",
    "start": "node server.js"
  }
}
JSON
⚠️
Utilise Multer 2.2 ou plus récent

Le 30 juin 2026, l’équipe Express a publié Multer 2.2.0, qui corrige deux failles de déni de service : CVE-2026-5079 (champs imbriqués à l’infini qui épuisent la mémoire) et CVE-2026-5038 (fichiers partiels laissés sur le disque quand l’envoi est interrompu). Si ton projet utilise encore Multer 1.x, npm update ne suffit pas : installe explicitement npm install multer@^2.2.0.

L’arborescence finale du projet :

upload-api/
├── package.json
├── server.js          ← le serveur Express
├── upload.js          ← la configuration de Multer
├── uploads/           ← les fichiers reçus (hors du dossier public !)
└── public/
    └── index.html     ← la page d'envoi avec glisser-déposer
Texte

Étape 2 : un premier upload de fichier Node.js

Commençons par la version la plus simple possible, pour comprendre le mécanisme :

// server.js (version 1, pour comprendre)
import express from 'express';
import multer from 'multer';

const app = express();
const upload = multer({ dest: 'uploads/' });

// 'fichier' doit correspondre au name="fichier" du champ du formulaire
app.post('/api/upload', upload.single('fichier'), (req, res) => {
  console.log(req.file);  // informations sur le fichier
  console.log(req.body);  // champs texte du formulaire
  res.json({ message: 'Fichier reçu', fichier: req.file.filename });
});

app.listen(3000, () => console.log('API sur http://localhost:3000'));
JavaScript

Lance npm run dev puis teste avec curl :

curl -F "[email protected]" -F "titre=Vacances" http://localhost:3000/api/upload
Bash

Dans la console, req.file ressemble à ceci :

{
  fieldname: 'fichier',
  originalname: 'photo.jpg',
  encoding: '7bit',
  mimetype: 'image/jpeg',
  destination: 'uploads/',
  filename: '3f2a9c1e8b7d6a5f4e3d2c1b0a998877',
  path: 'uploads/3f2a9c1e8b7d6a5f4e3d2c1b0a998877',
  size: 248137
}
JavaScript

Ça fonctionne, mais cette version a trois défauts sérieux : aucune limite de taille, aucun filtre de type et un nom de fichier sans extension. On corrige tout ça.

Étape 3 : configurer le stockage sur disque

Avec diskStorage, tu décides toi-même du dossier et du nom de chaque fichier. Crée un fichier upload.js dédié à la configuration :

// upload.js
import multer from 'multer';
import path from 'node:path';
import crypto from 'node:crypto';

const EXTENSIONS = {
  'image/jpeg': '.jpg',
  'image/png': '.png',
  'image/webp': '.webp',
  'application/pdf': '.pdf',
};

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, path.resolve('uploads'));
  },
  filename: (req, file, cb) => {
    // Nom aléatoire + extension déduite du type : on n'utilise JAMAIS le nom d'origine
    const nom = crypto.randomUUID() + EXTENSIONS[file.mimetype];
    cb(null, nom);
  },
});
JavaScript

Pourquoi ne pas garder le nom d’origine ? Parce qu’il vient de l’utilisateur. Un nom comme ../../server.js pourrait tenter d’écrire hors du dossier prévu (path traversal), deux utilisateurs peuvent envoyer photo.jpg en même temps, et des caractères spéciaux peuvent poser problème selon le système. Un UUID est unique, prévisible pour toi et sans danger. Si tu veux afficher le nom d’origine, garde-le en base de données, pas sur le disque.

Étape 4 : limiter la taille et filtrer les types de fichiers

On ajoute maintenant deux protections essentielles : les limites et le filtre.

// upload.js (suite)
const TYPES_AUTORISES = Object.keys(EXTENSIONS);

function fileFilter(req, file, cb) {
  if (TYPES_AUTORISES.includes(file.mimetype)) {
    cb(null, true);
  } else {
    const erreur = new multer.MulterError('LIMIT_UNEXPECTED_FILE', file.fieldname);
    erreur.message = 'Type de fichier non autorisé : ' + file.mimetype;
    cb(erreur);
  }
}

export const upload = multer({
  storage,
  fileFilter,
  limits: {
    fileSize: 5 * 1024 * 1024, // 5 Mo maximum par fichier
    files: 5,                  // 5 fichiers maximum par requête
    fields: 10,                // 10 champs texte maximum
    fieldSize: 100 * 1024,     // 100 Ko maximum par champ texte
    parts: 15,                 // total fichiers + champs
  },
});
JavaScript
LimiteRôleValeur conseillée
fileSizeTaille maximale d’un fichierSelon l’usage : 2 à 10 Mo pour des images
filesNombre de fichiers par requêteLe strict nécessaire
fieldsNombre de champs texteLe nombre de champs de ton formulaire
fieldSizeTaille d’un champ texte100 Ko suffisent largement
partsTotal des partiesfiles + fields
🛡️
Le type MIME envoyé par le navigateur n’est pas fiable

file.mimetype est déclaré par le client. Un attaquant peut envoyer un script PHP en prétendant que c’est une image/jpeg. Le filtre de Multer est une première barrière pratique, mais la vraie vérification se fait sur le contenu du fichier, à l’étape 6.

Étape 5 : un, plusieurs ou des champs différents

Multer propose plusieurs méthodes selon la forme de ton formulaire :

MéthodeCas d’usageRésultat
upload.single('avatar')Un seul fichierreq.file
upload.array('photos', 5)Plusieurs fichiers dans le même champreq.files (tableau)
upload.fields([{ name: 'cv', maxCount: 1 }, { name: 'lettres', maxCount: 3 }])Plusieurs champs fichiersreq.files.cv, req.files.lettres
upload.none()Formulaire multipart sans fichierreq.body uniquement
upload.any()N’importe quel champÀ éviter : tu ne contrôles plus rien

Exemple avec une galerie de photos :

app.post('/api/galerie', upload.array('photos', 5), (req, res) => {
  const fichiers = req.files.map(f => ({ nom: f.filename, taille: f.size }));
  res.status(201).json({ total: fichiers.length, fichiers });
});
JavaScript
💡
Évite upload.any()

Avec any(), n’importe quel champ fichier est accepté, y compris ceux que tu n’attendais pas. Déclare toujours précisément les champs avec single, array ou fields : c’est plus sûr et le code est plus lisible.

Étape 6 : vérifier le vrai contenu du fichier

Chaque format de fichier commence par une signature binaire, appelée nombre magique : un JPEG commence par les octets FF D8 FF, un PNG par 89 50 4E 47, un PDF par %PDF. Le paquet file-type lit ces premiers octets et te dit ce que le fichier est vraiment.

// verifier.js
import { fileTypeFromFile } from 'file-type';
import fs from 'node:fs/promises';

const AUTORISES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']);

export async function verifierContenu(req, res, next) {
  const fichiers = req.file ? [req.file] : (req.files ?? []);
  try {
    for (const f of fichiers) {
      const type = await fileTypeFromFile(f.path);
      if (!type || !AUTORISES.has(type.mime) || type.mime !== f.mimetype) {
        // On supprime TOUS les fichiers de la requête, pas seulement le fautif
        await Promise.all(fichiers.map(x => fs.rm(x.path, { force: true })));
        return res.status(415).json({ erreur: `Contenu invalide pour « ${f.originalname} »` });
      }
    }
    next();
  } catch (erreur) {
    next(erreur);
  }
}
JavaScript

Et on l’enchaîne juste après Multer :

app.post('/api/upload', upload.single('fichier'), verifierContenu, (req, res) => {
  res.status(201).json({ fichier: req.file.filename, taille: req.file.size });
});
JavaScript

Ainsi, un fichier .exe renommé en photo.jpg est détecté et supprimé immédiatement.

Étape 7 : gérer proprement les erreurs

Quand une limite est dépassée, Multer lève une MulterError avec un code précis. Sans gestionnaire d’erreurs, Express renverrait une page HTML générique avec un statut 500. On ajoute un middleware d’erreur qui traduit chaque cas en réponse JSON claire :

// server.js (en bas, après toutes les routes)
import multer from 'multer';

const MESSAGES = {
  LIMIT_FILE_SIZE: 'Fichier trop volumineux (5 Mo maximum).',
  LIMIT_FILE_COUNT: 'Trop de fichiers (5 maximum).',
  LIMIT_UNEXPECTED_FILE: 'Champ de fichier inattendu ou type non autorisé.',
  LIMIT_FIELD_COUNT: 'Trop de champs dans le formulaire.',
  LIMIT_PART_COUNT: 'Formulaire trop complexe.',
};

app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    const statut = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
    return res.status(statut).json({ erreur: MESSAGES[err.code] ?? err.message });
  }
  console.error(err);
  res.status(500).json({ erreur: 'Erreur interne du serveur.' });
});
JavaScript
Code MulterStatut HTTPSignification
LIMIT_FILE_SIZE413Le fichier dépasse fileSize
LIMIT_FILE_COUNT400Plus de fichiers que files
LIMIT_UNEXPECTED_FILE400Champ inconnu ou refusé par le filtre
LIMIT_FIELD_COUNT400Trop de champs texte
LIMIT_PART_COUNT400Trop de parties au total

Étape 8 : le serveur complet

Voici server.js dans sa version finale, qui assemble tout :

// server.js
import express from 'express';
import multer from 'multer';
import path from 'node:path';
import { upload } from './upload.js';
import { verifierContenu } from './verifier.js';

const app = express();
app.use(express.static('public')); // la page d'envoi

app.post('/api/upload', upload.single('fichier'), verifierContenu, (req, res) => {
  if (!req.file) {
    return res.status(400).json({ erreur: 'Aucun fichier reçu.' });
  }
  res.status(201).json({
    id: req.file.filename,
    nomOrigine: req.file.originalname,
    taille: req.file.size,
    type: req.file.mimetype,
  });
});

app.post('/api/galerie', upload.array('photos', 5), verifierContenu, (req, res) => {
  res.status(201).json({ total: req.files.length, ids: req.files.map(f => f.filename) });
});

// Téléchargement contrôlé : on n'expose jamais le dossier uploads directement
app.get('/api/fichiers/:id', (req, res) => {
  const id = path.basename(req.params.id); // neutralise les « ../ »
  res.sendFile(path.resolve('uploads', id), {
    headers: { 'X-Content-Type-Options': 'nosniff' },
  }, err => {
    if (err) res.status(404).json({ erreur: 'Fichier introuvable.' });
  });
});

app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    const statut = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
    return res.status(statut).json({ erreur: err.message });
  }
  console.error(err);
  res.status(500).json({ erreur: 'Erreur interne du serveur.' });
});

app.listen(3000, () => console.log('API d’upload sur http://localhost:3000'));
JavaScript
💡
Pourquoi une route de téléchargement plutôt que express.static(‘uploads’) ?

Servir le dossier directement permettrait d’accéder à n’importe quel fichier sans contrôle. Avec une route dédiée, tu peux ajouter une vérification de droits (l’utilisateur a-t-il le droit de voir ce fichier ?), journaliser les téléchargements et forcer des en-têtes de sécurité.

Étape 9 : la page d’envoi avec glisser-déposer et progression

fetch() ne permet pas encore de suivre la progression d’un envoi dans tous les navigateurs. On utilise donc XMLHttpRequest, qui expose l’événement upload.onprogress :

<!-- public/index.html -->
<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Envoi de fichiers</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 560px; margin: 40px auto; }
    #zone { border: 2px dashed #94a3b8; border-radius: 14px; padding: 40px; text-align: center; cursor: pointer; }
    #zone.survol { border-color: #2563eb; background: #eff6ff; }
    progress { width: 100%; height: 14px; margin-top: 16px; }
  </style>
</head>
<body>
  <h1>Envoyer un fichier</h1>
  <div id="zone">Glisse ton fichier ici ou clique pour le choisir
<small>JPG, PNG, WebP ou PDF · 5 Mo max</small></div>
  <input type="file" id="choix" accept=".jpg,.jpeg,.png,.webp,.pdf" hidden>
  <progress id="barre" value="0" max="100" hidden></progress>
  <p id="statut"></p>

  <script>
    const zone = document.getElementById('zone');
    const choix = document.getElementById('choix');
    const barre = document.getElementById('barre');
    const statut = document.getElementById('statut');

    zone.addEventListener('click', () => choix.click());
    choix.addEventListener('change', () => envoyer(choix.files[0]));
    zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('survol'); });
    zone.addEventListener('dragleave', () => zone.classList.remove('survol'));
    zone.addEventListener('drop', e => {
      e.preventDefault();
      zone.classList.remove('survol');
      envoyer(e.dataTransfer.files[0]);
    });

    function envoyer(fichier) {
      if (!fichier) return;
      if (fichier.size > 5 * 1024 * 1024) {
        statut.textContent = 'Fichier trop volumineux (5 Mo maximum).';
        return;
      }
      const donnees = new FormData();
      donnees.append('fichier', fichier);

      const xhr = new XMLHttpRequest();
      xhr.open('POST', '/api/upload');
      barre.hidden = false;
      xhr.upload.onprogress = e => {
        if (e.lengthComputable) barre.value = Math.round((e.loaded / e.total) * 100);
      };
      xhr.onload = () => {
        const reponse = JSON.parse(xhr.responseText);
        statut.textContent = xhr.status === 201
          ? `✅ « ${reponse.nomOrigine} » envoyé (${Math.round(reponse.taille / 1024)} Ko)`
          : `❌ ${reponse.erreur}`;
      };
      xhr.onerror = () => { statut.textContent = '❌ Erreur réseau.'; };
      xhr.send(donnees);
    }
  </script>
</body>
</html>
HTML

Remarque : on vérifie la taille aussi côté navigateur, pour éviter à l’utilisateur d’attendre la fin d’un envoi qui sera refusé. Mais cette vérification est uniquement un confort : la vraie limite reste celle du serveur, que personne ne peut contourner.

Stockage en mémoire, cloud et redimensionnement d’images

Le stockage sur disque convient à la plupart des projets. Deux autres cas reviennent souvent.

Traiter le fichier avant de l’enregistrer (redimensionner une photo, par exemple) : utilise memoryStorage. Le fichier est disponible dans req.file.buffer, sans passer par le disque.

import multer from 'multer';
import sharp from 'sharp'; // npm install sharp
import crypto from 'node:crypto';
import path from 'node:path';

const uploadMemoire = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024, files: 1 },
});

app.post('/api/avatar', uploadMemoire.single('avatar'), async (req, res, next) => {
  try {
    const nom = crypto.randomUUID() + '.webp';
    await sharp(req.file.buffer)
      .rotate()                         // respecte l'orientation EXIF
      .resize(256, 256, { fit: 'cover' })
      .webp({ quality: 85 })
      .toFile(path.resolve('uploads', nom)); // les métadonnées EXIF (GPS…) ne sont pas recopiées
    res.status(201).json({ avatar: nom });
  } catch (erreur) {
    next(erreur);
  }
});
JavaScript
⚠️
memoryStorage et grosses limites ne font pas bon ménage

En mémoire, chaque fichier occupe de la RAM jusqu’à la fin du traitement. Dix envois simultanés de 50 Mo, c’est 500 Mo de mémoire. Garde memoryStorage pour de petits fichiers, avec une limite fileSize basse.

Stocker dans le cloud (Amazon S3, Cloudflare R2, Google Cloud Storage) : pour une application déployée sur plusieurs serveurs ou sur une plateforme sans disque persistant, envoie le fichier vers un service de stockage objet. Des moteurs de stockage Multer existent (comme multer-s3), ou tu peux envoyer le buffer avec le SDK du fournisseur.

La checklist sécurité de l’upload de fichiers Node.js

  • Multer en version 2.2.0 ou plus, dépendances auditées avec npm audit.
  • Limites fileSize, files, fields et parts toujours définies.
  • Liste blanche des types autorisés, jamais de liste noire.
  • Vérification du contenu réel avec file-type.
  • Nom de fichier généré par le serveur (UUID), jamais le nom d’origine.
  • Dossier uploads/ en dehors du dossier public, servi par une route contrôlée.
  • En-tête X-Content-Type-Options: nosniff sur les téléchargements.
  • Contrôle d’accès : seul le propriétaire (ou un rôle autorisé) peut télécharger un fichier.
  • Limite de taille aussi au niveau du serveur frontal (Nginx client_max_body_size).
  • Pour les plateformes publiques, analyse antivirus (ClamAV par exemple) des fichiers reçus.

Ces règles correspondent directement aux catégories de l’OWASP Top 10, en particulier la mauvaise configuration et la chaîne d’approvisionnement.

Dépannage

ProblèmeCauseSolution
req.file est undefinedLe nom du champ ne correspond pasupload.single('fichier') doit correspondre à name="fichier" (ou FormData.append('fichier', …))
req.body est videLes champs texte sont envoyés après le fichierPlace les champs texte avant le fichier dans le formulaire
Erreur Unexpected fieldChamp fichier non déclaréDéclare-le dans single, array ou fields
Erreur ENOENT à l’enregistrementLe dossier de destination n’existe pasCrée uploads/ (ou fs.mkdirSync('uploads', { recursive: true }))
Le fichier arrive à 0 octetTu as défini Content-Type à la main dans fetchLaisse le navigateur le définir : il doit inclure la frontière (boundary)
Erreur CORS depuis un autre domaineFront et API sur des origines différentesConfigure CORS côté serveur, voir notre guide « CORS policy blocked »
Upload de fichiers Node.js : les points essentiels à retenir
Les points essentiels à retenir de ce guide.

FAQ

Multer fonctionne-t-il avec Express 5 ?

Oui. Multer est maintenu par l’équipe d’Express et fonctionne avec Express 4 et Express 5. Avec Express 5, les erreurs levées dans un gestionnaire asynchrone sont transmises automatiquement au middleware d’erreur.

Quelle taille maximale autoriser pour un upload de fichiers Node.js ?

Il n’y a pas de valeur universelle : choisis la plus petite qui couvre ton usage réel. 2 à 5 Mo pour des photos de profil, 10 à 20 Mo pour des documents. Pour des vidéos, préfère un envoi direct vers un stockage cloud avec une URL présignée.

Faut-il stocker les fichiers en base de données ?

Rarement. Stocke le fichier sur disque ou dans un stockage objet, et garde en base uniquement ses métadonnées : identifiant, nom d’origine, taille, type, propriétaire, date. La base reste légère et les sauvegardes rapides.

Comment supprimer les fichiers orphelins ?

Enregistre chaque fichier en base au moment de l’envoi, puis planifie une tâche (par exemple chaque nuit) qui supprime les fichiers du disque absents de la base, et inversement.

Conclusion

Tu disposes maintenant d’une API d’upload de fichiers Node.js complète et sécurisée : Multer pour lire les formulaires multipart, un stockage avec des noms générés, des limites strictes, une vérification du contenu réel, une gestion d’erreurs claire et une interface avec glisser-déposer et progression.

La prochaine étape logique : associer chaque fichier à un utilisateur en base de données et vérifier ses droits sur la route de téléchargement. Et si ton application doit prévenir l’utilisateur par e-mail quand son fichier a été traité, notre guide pour envoyer des e-mails avec Node.js et Nodemailer te montre comment faire. La documentation complète de Multer est disponible sur son dépôt GitHub officiel.

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 *