Développement Web

Comment sécuriser un formulaire PHP contre les injections SQL

13 min de lecture

Prérequis

  • Niveau : bases PHP + SQL (cf. PHP et MySQL).
  • Outils : XAMPP/Laragon (PHP 8.2+), MySQL/MariaDB.
  • Temps estimé : 2 h.

Pourquoi cet article est important ?

L’injection SQL reste dans le top 3 OWASP année après année. C’est aussi la faille la plus simple à éviter : les requêtes préparées la rendent impossible par construction. Aucune raison de coder différemment en 2026 (informations vérifiées en avril 2026, susceptibles d’évoluer).

Les injections SQL : la faille n°1 des sites web

Une injection SQL se produit quand un attaquant insère du code SQL malveillant dans un champ de formulaire, et que votre code PHP l’envoie directement à la base de données sans le filtrer. C’est la faille de sécurité la plus courante et la plus dangereuse — elle permet de voler, modifier ou supprimer toutes vos données. Ce tutoriel vous montre comment vous en protéger définitivement.

Le code vulnérable : ne faites JAMAIS ça

// ❌ DANGEREUX — NE JAMAIS UTILISER EN PRODUCTION
$username = $_POST['username'];
$password = $_POST['password'];

$sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
$result = mysqli_query($conn, $sql);

L’attaque : si un utilisateur entre ' OR '1'='1 comme nom d’utilisateur, la requête SQL devient :

SELECT * FROM users WHERE username = '' OR '1'='1' AND password = ''

La condition '1'='1' est toujours vraie → l’attaquant accède au premier compte de la base (souvent l’administrateur). Avec '; DROP TABLE users; --, il peut supprimer toute votre table.

La solution : les requêtes préparées (prepared statements)

Les requêtes préparées séparent le code SQL des données. La base de données reçoit d’abord la structure de la requête, puis les valeurs séparément. Même si la valeur contient du SQL malveillant, elle est traitée comme une simple chaîne de texte.

Avec PDO (recommandé)

// Connexion avec PDO
$pdo = new PDO(
  'mysql:host=localhost;dbname=mabase;charset=utf8mb4',
  'utilisateur',
  'motdepasse',
  [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES => false  // Requêtes préparées natives
  ]
);

// ✅ SÉCURISÉ — Requête préparée avec paramètres nommés
$stmt = $pdo->préparé('SELECT * FROM users WHERE username = :username AND password = :password');
$stmt->exécuté([
  ':username' => $_POST['username'],
  ':password' => $_POST['password']
]);
$user = $stmt->fetch();

Avec MySQLi

// ✅ SÉCURISÉ — Requête préparée avec MySQLi
$stmt = $conn->préparé('SELECT * FROM users WHERE username = ? AND password = ?');
$stmt->bind_param('ss', $_POST['username'], $_POST['password']);
$stmt->exécuté();
$result = $stmt->get_result();
$user = $result->fetch_assoc();

PDO vs MySQLi : utilisez PDO. Il supporte 12 bases de données différentes (MySQL, PostgreSQL, SQLite…), a une API plus propre, et les paramètres nommés (:username) sont plus lisibles que les ?.

Sécurisation complète d’un formulaire

La protection contre les injections SQL n’est qu’une partie de la sécurisation. Voici la checklist complète :

1. Valider les entrées côté serveur

// Vérifier que les champs existent et ne sont pas vides
if (empty($_POST['email']) || empty($_POST['password'])) {
  die('Tous les champs sont requis');
}

// Valider le format email
$email = filter_var($_POST['email'], FILTER_VALIDATE_EMAIL);
if (!$email) {
  die('Email invalide');
}

// Limiter la longueur
if (strlen($_POST['password']) < 8 || strlen($_POST['password']) > 128) {
  die('Le mot de passe doit faire entre 8 et 128 caractères');
}

2. Hasher les mots de passe

// À l'inscription : hasher le mot de passe
$hash = password_hash($_POST['password'], PASSWORD_DEFAULT);

$stmt = $pdo->préparé('INSERT INTO users (email, password_hash) VALUES (:email, :hash)');
$stmt->exécuté([':email' => $email, ':hash' => $hash]);

// À la connexion : vérifier le hash
$stmt = $pdo->préparé('SELECT * FROM users WHERE email = :email');
$stmt->exécuté([':email' => $email]);
$user = $stmt->fetch();

if ($user && password_verify($_POST['password'], $user['password_hash'])) {
  // Connexion réussie
  session_regenerate_id(true);
  $_SESSION['user_id'] = $user['id'];
} else {
  // Échec — message volontairement vague
  echo 'Email ou mot de passe incorrect';
}

Règles critiques :

  • Ne stockez JAMAIS les mots de passe en clair ou en MD5/SHA1
  • password_hash() utilise bcrypt par défaut — c’est le standard actuel
  • password_verify() compare de manière sécurisée (résistant aux attaques timing)
  • Le message d’erreur ne doit pas dire si c’est l’email ou le mot de passe qui est faux

3. Protéger contre le CSRF

// Générer un token CSRF
session_start();
$csrf_token = bin2hex(random_bytes(32));
$_SESSION['csrf_token'] = $csrf_token;

// Dans le formulaire HTML
echo '<input type="hidden" name="csrf_token" value="' . $csrf_token . '">';

// À la soumission : vérifier le token
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
  die('Requête invalide');
}

4. Échapper l’affichage (anti-XSS)

// Quand vous affichez des données utilisateur dans le HTML
echo htmlspecialchars($user['name'], ENT_QUOTES, 'UTF-8');

Checklist de sécurité formulaire

Protection Contre Comment
Requêtes préparées Injection SQL PDO avec paramètres
Validation input Données invalides filter_var, strlen, regex
password_hash Vol de mots de passe Bcrypt automatique
Token CSRF Requêtes forgées Token unique par session
htmlspecialchars XSS Échapper l’affichage
HTTPS Interception réseau Certificat SSL (Let’s Encrypt)
Rate limiting Brute force Limiter les tentatives de connexion

Erreurs fréquentes

Concaténation au lieu de paramètres liés

Cause : on utilise "WHERE id = " . $_GET['id'] par habitude.
Solution : 100 % des requêtes avec données utilisateur passent par prepare() + execute([...]). Aucune exception.

PDO::ATTR_EMULATE_PREPARES à true

Cause : par défaut, PDO simule les requêtes préparées côté client, ce qui peut réintroduire des risques.
Solution : mettez PDO::ATTR_EMULATE_PREPARES => false à la connexion (déjà fait dans cet article).

Mots de passe en MD5/SHA1

Cause : ces algorithmes sont cassés depuis > 15 ans.
Solution : password_hash($mdp, PASSWORD_DEFAULT) — bcrypt par défaut (PHP 8), Argon2id disponible avec PASSWORD_ARGON2ID.

Token CSRF unique par session non invalidé

Cause : on garde le même token toute la session.
Solution : régénérez après actions sensibles, et utilisez hash_equals() (résistant aux attaques timing) pour la comparaison.

Exercice

Créez un formulaire d’inscription complet et sécurisé :

  1. Champs : email, mot de passe, confirmation du mot de passe
  2. Validation côté serveur de tous les champs
  3. Requête préparée PDO pour l’insertion
  4. Mot de passe hashé avec password_hash()
  5. Protection CSRF
  6. Messages d’erreur clairs mais qui ne révèlent pas d’informations sensibles

Sur un angle proche

Étape 1 : comprendre l’attaque avant d’écrire la défense

Une injection SQL exploite le fait qu’un développeur concatène des entrées utilisateur dans une requête SQL. Si un visiteur tape ' OR '1'='1 dans un champ « email », et que vous construisez la requête avec "SELECT * FROM users WHERE email='" . $_POST['email'] . "'", la base reçoit SELECT * FROM users WHERE email='' OR '1'='1'. Toutes les lignes remontent. L’attaquant est connecté.

// Code vulnérable à NE PAS reproduire
$email = $_POST['email'];
$sql = "SELECT * FROM users WHERE email='$email'";
$res = mysqli_query($conn, $sql);

Cette ligne est la racine de plus de 50 % des fuites de données documentées par l’OWASP Top 10. La parade est simple : ne jamais concaténer. Toujours utiliser des requêtes préparées avec paramètres liés. C’est l’objet des étapes suivantes.

Étape 2 : passer à PDO en mode strict

PDO (PHP Data Objects) est l’extension officielle pour parler aux bases de données depuis PHP. Activez le mode exception et désactivez l’émulation des préparations. Cette configuration force le pilote à envoyer des requêtes vraiment préparées au serveur MySQL.

$dsn = "mysql:host=localhost;dbname=appdb;charset=utf8mb4";
$pdo = new PDO($dsn, $user, $pwd, [
  PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  PDO::ATTR_EMULATE_PREPARES => false,
]);

Le paramètre charset=utf8mb4 est crucial : il évite les attaques par bytes invalides documentées en 2006 mais encore exploitables sur des serveurs mal configurés. Le mode exception remonte une PDOException à chaque erreur, ce qui force le développeur à gérer les cas d’échec.

Étape 3 : écrire une requête préparée avec paramètres nommés

Préparez la requête une fois, exécutez-la avec les valeurs utilisateur. Le pilote MySQL reçoit séparément la structure SQL et les données. Aucune concaténation n’est possible côté serveur. Les paramètres nommés (:email) sont plus lisibles que les ? positionnels, surtout quand la requête a 5 ou 6 paramètres.

$stmt = $pdo->prepare("SELECT id, nom FROM users WHERE email = :email AND actif = 1");
$stmt->execute([':email' => $_POST['email']]);
$user = $stmt->fetch();

Si l’attaquant envoie ' OR '1'='1 dans le champ email, le pilote l’envoie tel quel comme valeur littérale. La requête échoue à trouver un email littéral contenant ces caractères, ce qui est exactement le comportement attendu. La défense est structurelle, pas comportementale.

Étape 4 : valider en plus de paramétrer

Les requêtes préparées bloquent l’injection SQL, mais elles n’empêchent pas un attaquant d’enregistrer un email malformé ou une chaîne de 10 000 caractères qui surcharge la base. Validez chaque entrée avec filter_var avant d’exécuter la requête.

$email = filter_var($_POST['email'], FILTER_VALIDATE_EMAIL);
if (!$email) {
  http_response_code(422);
  exit('Email invalide');
}
// Maintenant seulement, exécuter la requête préparée.

Le code retour 422 (Unprocessable Entity) est plus précis que 400 et signale que la requête est syntaxiquement valide mais sémantiquement fausse. Cette validation côté serveur est obligatoire : ne faites jamais confiance au JavaScript du formulaire, qu’un attaquant peut contourner avec un simple curl.

Étape 5 : protéger les recherches LIKE

Les requêtes LIKE sont un piège classique : si le visiteur tape % ou _, ces caractères ont une signification SQL spéciale. Échappez-les avant de les passer en paramètre.

$terme = $_GET['q'];
$terme = str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $terme);
$stmt = $pdo->prepare("SELECT id, titre FROM articles WHERE titre LIKE :q LIMIT 20");
$stmt->execute([':q' => '%' . $terme . '%']);

Sans cette échappement, un visiteur qui tape % récupère tous les articles, et un attaquant peut surcharger la base avec des recherches très lentes (denial of service). La limite LIMIT 20 est une seconde ligne de défense contre l’épuisement mémoire.

Étape 6 : journaliser les tentatives suspectes

Une tentative d’injection laisse des traces : caractères spéciaux dans les champs, longueur anormale, motifs UNION SELECT ou OR 1=1. Loggez ces requêtes pour analyse forensique. À Dakar comme ailleurs, savoir qui attaque permet de déclencher un blocage IP au niveau Cloudflare ou Wordfence.

$suspect = preg_match('/(\bunion\b|\bselect\b|--|\bor\b\s+\d+=\d+)/i', $_POST['email']);
if ($suspect) {
  error_log(sprintf("[SQLi suspect] IP=%s email=%s", $_SERVER['REMOTE_ADDR'], $_POST['email']));
}

Le motif est volontairement large. Quelques faux positifs (un email contenant le mot « union ») restent acceptables face au gain de visibilité. Les logs sont à archiver pendant au moins 6 mois pour la conformité avec la loi sénégalaise sur la protection des données.

Étape 7 : utiliser un compte MySQL aux droits limités

Le compte que votre application PHP utilise pour se connecter à MySQL ne doit avoir que les droits strictement nécessaires : SELECT, INSERT, UPDATE, DELETE sur les tables applicatives, jamais GRANT, FILE ou DROP. Si une injection passe malgré tout, l’impact reste limité.

-- À exécuter en tant que root MySQL
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'mot_de_passe_long';
GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'app_user'@'localhost';
FLUSH PRIVILEGES;

Vérifiez ensuite avec SHOW GRANTS FOR 'app_user'@'localhost';. Aucune ligne ALL PRIVILEGES ne doit apparaître. Cette segmentation est l’application du principe du moindre privilège, recommandé par l’ANSI Sénégal et l’ANSSI française.

Étape 8 : tester avec sqlmap en environnement de préproduction

sqlmap est l’outil de référence pour détecter les injections SQL automatiquement. Lancez-le contre votre application en préproduction (jamais en production sans accord écrit) pour vérifier qu’aucune injection ne passe.

sqlmap -u "https://preprod.votre-site.tld/login.php"   --data="email=test@example.com&password=test"   --level=3 --risk=2 --batch

Si sqlmap répond all tested parameters do not appear to be injectable, la protection tient. Si vous voyez parameter ... is vulnerable, corrigez immédiatement le code identifié et relancez le test. L’option --batch répond automatiquement oui aux questions, utile en intégration continue.

Pour creuser ce sujet, consultez notre tutoriel WooCommerce qui applique ces principes aux endpoints e-commerce, et notre guide HTTP/3 pour le transport sécurisé des requêtes.

Étape 9 : durcir la couche frontale et les en-têtes HTTP

Une défense en profondeur combine paramètres liés côté SQL et en-têtes HTTP côté serveur. Activez Content-Security-Policy pour limiter l’exécution de scripts injectés, X-Content-Type-Options: nosniff pour bloquer le sniffing MIME, et Strict-Transport-Security pour forcer HTTPS.

// En tête de chaque page sensible
header("Content-Security-Policy: default-src 'self'; script-src 'self'");
header("X-Content-Type-Options: nosniff");
header("Strict-Transport-Security: max-age=31536000; includeSubDomains");

Ces trois en-têtes ne bloquent pas l’injection SQL elle-même, mais limitent fortement l’exploitation post-injection (vol de cookie, exécution de payload XSS, downgrade vers HTTP). Vérifiez l’application avec curl -I https://votre-site.tld et l’outil en ligne securityheaders.com.

Étape 10 : automatiser les revues de code avec un linter sécurité

Un développeur fatigué qui copie-colle un ancien snippet peut réintroduire une concaténation. Branchez un linter de sécurité dans votre intégration continue : Psalm avec le plugin TaintCheck, ou PHPStan niveau 9. Ces outils détectent les flux non assainis du $_POST vers la base.

# Dans .github/workflows/ci.yml
- name: Audit sécurité PHP
  run: vendor/bin/psalm --taint-analysis

Le linter remonte les chemins suspects sous forme de rapport. Une concaténation directe dans mysqli_query est marquée en niveau ERREUR. Le pipeline échoue et la pull request ne peut pas être fusionnée tant que le développeur n’a pas corrigé.

Partager