Retour au blog
Journal · 28 juin 2026

Sécuriser un formulaire de contact: ce que la plupart oublient

8 min de lectureMarius Sergent
Schéma des couches de défense d'un formulaire de contact, du client à l'envoi
Schéma des couches de défense d'un formulaire de contact, du client à l'envoi

Un formulaire de contact, c'est souvent la dernière chose qu'on code, vite fait, parce que « ce n'est qu'un formulaire ». Trois champs, un bouton, un e-mail qui part. Sauf que derrière le bouton, il y a une route POST publique, ouverte sur Internet, qui déclenche une action côté serveur : un envoi d'e-mail, via un service facturé à l'usage. Autrement dit, une cible. Pas la plus juteuse du web, mais une cible quand même, et bien plus exposée que la page « à propos » juste à côté.

Cette semaine, j'ai audité celui de ce portfolio. J'y ai trouvé une porte grande ouverte que j'avais moi-même posée des mois plus tôt, sans m'en rendre compte. J'y reviens, c'est le cœur de l'article. Avant ça, le constat que je veux faire passer : la sécurité d'un formulaire ne tient pas dans une case à cocher. C'est un empilement de petites défenses qui, prises une à une, paraissent anecdotiques, et qui ensemble tiennent. Le maillon qui lâche, dans mon expérience, c'est presque toujours un raccourci de dev qu'on a oublié d'enlever.

Le widget reCAPTCHA ne protège rien tout seul

reCAPTCHA v3, c'est devenu un réflexe. On ajoute le provider côté client, le petit badge apparaît en bas à droite, et on se sent couvert. C'est une illusion confortable. Le widget client génère un token, rien de plus. Tant que personne ne vérifie ce token côté serveur, il ne vaut rien : un bot n'a même pas besoin de charger le script Google, il poste directement sur ton endpoint avec un champ recaptchaToken bidon, ou vide, ou recopié.

La vraie barrière est sur le serveur. À la réception de la requête, tu rappelles l'API de vérification de Google avec ta clé secrète, et tu lis ce qu'elle te renvoie : le success, le score (entre 0 et 1, plus c'est haut plus c'est probablement humain), et l'action attendue. Les trois doivent être bons.

async function verifyRecaptchaV3(token: string, expectedAction = "contact_form") {
  const secretKey = process.env.RECAPTCHA_SECRET_KEY;
  if (!secretKey) return { isValid: false, score: 0 };
 
  const response = await fetch("https://www.google.com/recaptcha/api/siteverify", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: `secret=${secretKey}&response=${token}`,
  });
  const data = await response.json();
 
  const isValid = data.success === true
    && data.action === expectedAction
    && data.score >= 0.5;
 
  return { isValid, score: data.score ?? 0 };
}

Le seuil de 0.5 est le point de réglage. Trop haut, tu bloques de vrais visiteurs un peu nerveux du clic. Trop bas, tu laisses passer les bots. 0.5 est un milieu raisonnable pour un site vitrine. Et vérifier l'action compte autant que le score : ça garantit que le token a bien été émis pour ton formulaire de contact, pas chipé sur une autre page du site.

L'histoire du token magique

Voilà la porte ouverte. En développant le formulaire, j'avais besoin de tester l'envoi sans me coltiner reCAPTCHA à chaque soumission locale. Classique. J'avais donc ajouté un petit contournement : si le token reçu valait la chaîne "test_token_no_recaptcha", le serveur considérait le captcha comme validé et passait à la suite. Pratique en dev. Le genre de béquille qu'on se promet d'enlever avant la mise en prod.

Sauf qu'elle est restée. Et pas qu'un peu : le front l'envoyait tout seul. La logique côté client disait, en substance, « si reCAPTCHA n'est pas disponible, envoie quand même le token de secours ». Or reCAPTCHA est indisponible bien plus souvent qu'on ne le croit : un bloqueur de pub qui filtre le script Google, une extension de confidentialité, la clé publique absente d'un environnement, un simple hoquet réseau au chargement. À chacun de ces cas, le navigateur d'un visiteur parfaitement légitime basculait sur le token magique. Et n'importe qui regardant le code client deux minutes voyait passer la chaîne en clair.

Résultat : un attaquant pouvait poster sur /api/contact autant qu'il voulait, sans jamais résoudre le moindre captcha, juste en envoyant "test_token_no_recaptcha". Toute la couche anti-bot, contournée par une ligne que j'avais écrite pour me simplifier la vie six mois plus tôt.

Le correctif tient en un renversement de logique. Si reCAPTCHA n'est pas disponible, on ne contourne pas, on bloque. Le serveur ne connaît plus aucun token de faveur. Le client, lui, refuse d'appeler l'API et affiche un message clair au lieu d'inventer un laissez-passer.

if (executeRecaptcha) {
  recaptchaToken = await executeRecaptcha("contact_form");
} else {
  // reCAPTCHA indisponible : on bloque, on n'envoie pas de token de secours
  setSubmitError("La vérification de sécurité est indisponible. Rafraîchissez la page.");
  return;
}

La leçon dépasse largement reCAPTCHA. Un bypass de dev qui traîne en prod, c'est une porte dérobée que tu as toi-même installée, documentée, et dont tu as oublié l'existence. Les pires failles ne sont presque jamais des attaques sophistiquées. Ce sont des raccourcis de confort qu'on a négligé de retirer. Quand je grep un projet avant une mise en prod, test, debug, bypass, skip et TODO sont les premiers mots que je cherche.

Limiter le débit, avant même de réfléchir

Mettons le captcha solide. Il reste un problème de volume. Sans plafond, rien n'empêche quelqu'un de marteler ton endpoint : quelques milliers de POST, et c'est ton quota d'envoi d'e-mails qui se vide (Resend, dans mon cas, facture à l'usage), ta boîte de réception qui déborde, ou ton serveur qui passe son temps à appeler l'API de Google pour rien.

Un rate-limit règle ça, et il doit s'exécuter en tout premier, avant la validation, avant l'appel à reCAPTCHA, avant la moindre opération coûteuse. L'idée : compter les requêtes par IP sur une fenêtre glissante, et refuser au-delà d'un seuil. Pour un site vitrine, pas besoin de Redis ni d'Upstash. Une Map en mémoire suffit largement, cinq requêtes par tranche de dix minutes et par IP.

const rateLimitMap = new Map<string, number[]>();
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
 
function checkRateLimit(ip: string): boolean {
  const now = Date.now();
  const windowStart = now - RATE_LIMIT_WINDOW_MS;
  const recent = (rateLimitMap.get(ip) ?? []).filter((t) => t > windowStart);
 
  if (recent.length >= RATE_LIMIT_MAX) {
    rateLimitMap.set(ip, recent);
    return false; // limite atteinte
  }
  recent.push(now);
  rateLimitMap.set(ip, recent);
  return true;
}

Deux détails qui font la différence. L'IP du client, derrière un reverse proxy (et tout le monde l'est, en pratique), ne se lit pas sur la socket : elle est dans l'en-tête x-forwarded-for, premier maillon de la liste. Si tu prends l'IP de la connexion directe, tu rate-limites ton propre proxy, c'est-à-dire tout le monde d'un coup. Second point, oui, une Map en mémoire se vide à chaque redéploiement, et ne tient pas sur plusieurs instances. Pour un portfolio à une instance et au trafic modeste, c'est un compromis que j'assume sans hésiter. Sortir Redis pour ça serait de l'ingénierie pour le plaisir. Sur une app à fort trafic ou multi-instance, là, le store partagé devient non négociable.

Ne jamais faire confiance à ce qui entre

Tout ce qui arrive d'un formulaire est hostile par défaut, jusqu'à preuve du contraire. Concrètement, deux gestes.

D'abord, valider la forme. Un schéma Zod décrit ce que tu acceptes (longueurs minimales, format d'e-mail, champs obligatoires), et tu refuses tout le reste avant de toucher à quoi que ce soit. C'est l'application directe du fail-fast : une donnée non conforme s'arrête à la porte, elle ne se balade pas dans ta logique métier.

const contactSchema = z.object({
  name: z.string().min(2),
  email: z.string().min(1).email(),
  subject: z.string().min(5),
  message: z.string().min(10),
  recaptchaToken: z.string().min(1),
});

Ensuite, échapper à la sortie. Mon serveur m'envoie un e-mail de notification en HTML à chaque message, avec le nom et le contenu interpolés dedans. Si j'injecte ces champs bruts dans le HTML, j'ouvre une injection : quelqu'un met une balise <a> ou une <img> dans le champ « message », et mon e-mail de notification se retrouve avec un faux lien ou une image piochée n'importe où. Pas le drame du siècle, mais c'est ma boîte de réception qui devient une surface d'attaque. La parade est triviale, échapper les cinq caractères qui comptent avant toute interpolation.

function escapeHtml(s: string): string {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

Valider ce qui entre, échapper ce qui sort. C'est la même règle vieille comme le web, appliquée à un canal qu'on oublie souvent de considérer comme tel : un e-mail rendu en HTML est une page web comme une autre.

Les secrets n'ont rien à faire dans le navigateur

Dernier point, et c'est plus un piège de plomberie qu'une faille à proprement parler. Avec Next.js, toute variable d'environnement préfixée NEXT_PUBLIC_ est inlinée dans le bundle JavaScript envoyé au client. Visible par n'importe qui ouvrant les outils de développement. La clé publique reCAPTCHA, elle, est faite pour ça, aucun problème. Mais la clé secrète reCAPTCHA et la clé d'API d'envoi d'e-mails n'ont rien à faire là. Elles se lisent au runtime, côté serveur uniquement, et restent sans le préfixe NEXT_PUBLIC_. Confondre les deux, c'est publier sa clé secrète à la vue de tous, et offrir à quelqu'un la possibilité d'envoyer des e-mails en ton nom. Le genre d'erreur qu'un scan de secrets dans la CI attrape, et c'est exactement pour ça qu'on en met un.

Aucune de ces couches, isolée, ne rend un formulaire « sûr ». Le captcha serveur sans rate-limit laisse passer le flood. Le rate-limit sans validation laisse passer les saletés. La validation sans échappement laisse passer l'injection. C'est l'empilement qui fait le travail, et le plus dur n'est pas de les écrire, c'est de penser à toutes, et de ne pas saboter l'ensemble avec une béquille de dev oubliée. Le formulaire le plus banal d'un site est souvent celui qui mérite le plus qu'on l'audite, justement parce que personne ne le regarde.

Si tu veux un œil extérieur sur le tien, parlons-en.

Un projet qui vous ressemble ?

Discutons de votre contexte, de vos contraintes et de vos objectifs.

Prendre contact