Connexion sécurisée : PHP, sessions, mot de passe et attaques courantes
Saisir un mot de passe dans un formulaire semble anodin. Pourtant, cette interface minimaliste ouvre l’accès à des espaces privés, souvent sensibles. Et dans l’ombre, des robots testent en boucle des identifiants, contournent les protections, ou injectent des requêtes malveillantes.
Dans Protection de base pour un formulaire, nous avons vu comment piéger les robots ou limiter les envois suspects. Ce nouvel article poursuit cette logique, mais à un autre niveau : nous allons parler de connexion sécurisée, de base de données, de gestion de session, et de vérification d’identité avec password_verify()
.
Objectif : comprendre ce que PHP gère pour nous, ce qu’il faut renforcer, et comment anticiper les attaques les plus courantes, en bâtissant un script robuste et documenté.
Ce que voit un utilisateur… ce que voit un robot
Un utilisateur humain voit un formulaire épuré, centré sur deux champs : identifiant et mot de passe. Il clique, il attend, il se connecte. Tout paraît normal. Mais en coulisse, ce formulaire devient une porte d’entrée vers votre application, et cette porte est souvent testée, brutée, forcée.
Un robot, lui, ne « voit » pas l’interface. Il explore le code HTML, détecte les input type="password"
, identifie les endpoints (login.php
, connect.php
, etc.), puis injecte des données automatiquement dans la requête. L’objectif : tester des centaines de combinaisons par minute, ou tenter des injections SQL pour court-circuiter les vérifications. Un exemple simple d’attaque automatisée via curl
:
curl -X POST -d "login[username]=admin&login[password]=' OR '1'='1" https://monsite.org/php/__login.php
Cette requête ne simule pas un formulaire, elle attaque directement le script PHP. Elle contourne toute interface, tout contrôle visuel ou JavaScript. C’est là que réside la fragilité fondamentale : ce que vous affichez à l’écran n’a aucune valeur défensive en soi. Ce qu’il faut retenir, c’est qu’un formulaire de connexion n’est jamais seul. Il est en interaction avec :
- une base de données,
- une session utilisateur,
- une logique de droits ou de redirections.
Et ce sont ces couches-là que les attaquants ciblent, sans jamais passer par les cases visibles du navigateur.
La parade classique : un script de connexion à étages
Avant les solutions toutes faites, nous avons souvent écrit nos propres scripts de connexion. Étape après étape, chaque portion de code validait un critère : champ vide, caractère suspect, identifiant introuvable, mot de passe incorrect, utilisateur inactif. Ce type de script fonctionnait comme un sas de sécurité, bloquant les intrus à chaque seuil.
Prenons un exemple réel inspiré d’un projet en production. À la réception du formulaire, le script :
- filtre les caractères interdits (
/
,=
,DROP
, etc.), - vérifie si l’adresse mail existe bien en base,
- compare le mot de passe via
password_verify()
si l’utilisateur est trouvé, - vérifie que le compte est actif (
etat = 1
), - initialise les données de session avec les droits associés,
- redirige vers une page sécurisée.
Chacune de ces étapes joue un rôle précis. La vérification d’un champ vide empêche les soumissions bâclées ou forcées. Le filtrage lexical anticipe les injections grossières. La vérification du mot de passe utilise un algorithme de hachage pour éviter toute comparaison brute en clair. Et la session, si elle est bien sécurisée (httponly
, secure
, regenerate_id()
), devient une enclave fiable jusqu’à la déconnexion.
Ce modèle « à étages » a le mérite de la lisibilité. Il rend chaque filtre visible et modifiable, ce qui est précieux dans un environnement maîtrisé. Mais à lui seul, il ne bloque ni les attaques par force brute, ni les tests de session volée, ni les redirections piégées.
C’est une fondation. Reste à savoir ce que PHP nous propose aujourd’hui par défaut, et ce qu’il nous faut compléter nous-mêmes.
Aujourd’hui, que fait PHP tout seul ? Que reste-t-il à faire ?
PHP a beaucoup évolué sur la question des mots de passe. Depuis la version 5.5, il propose la fonction password_hash()
pour générer un mot de passe sécurisé, et password_verify()
pour le valider lors de la connexion. Ces fonctions utilisent par défaut l’algorithme Bcrypt, qui applique un salage automatique et un coût de calcul modulable, empêchant toute comparaison directe entre deux valeurs.
Autrement dit, PHP ne compare jamais password == motdepasse
. Il compare le résultat de password_verify($tentative, $hash_stocké)
, où le hash contient déjà des informations de version et de configuration. Cela rend les attaques par dictionnaire bien plus longues, et l’algorithme peut être mis à jour sans réécrire toute l’application.
Mais cette couche de sécurité ne protège que le cœur du mot de passe. Autour, tout reste à faire. PHP ne bloque pas un robot qui envoie 10 000 requêtes en une heure. Il ne sécurise pas votre formulaire contre une injection SQL si vous oubliez d’utiliser les requêtes préparées ($dbh->prepare(...)
). Et il ne protège pas vos sessions contre une réutilisation ou une attaque par fixation d’ID si vous ne les régénérez jamais.
En résumé, PHP vous aide à protéger la valeur du mot de passe, mais ne peut en rien sécuriser l’acte de connexion lui-même. C’est à nous de construire l’environnement : filtrer, contrôler, enregistrer, temporiser… et adapter.
Quelles attaques, quels scénarios ?
Les attaques sur un formulaire de connexion ne visent pas seulement à « deviner » un mot de passe. Elles cherchent aussi à forcer un comportement, détourner une requête ou exploiter une faille périphérique. Comprendre les scénarios les plus courants permet d’anticiper les protections à mettre en place, sans s’illusionner sur un simple password_verify()
.
Le scénario le plus basique reste l’attaque par force brute. Un robot teste automatiquement des milliers de combinaisons, souvent issues de bases de données compromises. L’objectif n’est pas de cibler un compte précis, mais de tomber statistiquement sur une correspondance. Si aucun verrou n’est en place, ces tentatives peuvent continuer indéfiniment.
Viennent ensuite les injections. Par exemple, si la requête SQL de connexion n’est pas préparée correctement, un champ login
contenant ' OR '1'='1
pourrait forcer l’accès sans identifiant valide :
SELECT * FROM users WHERE email = '' OR '1'='1'
Autre angle d’attaque : la fixation ou le vol de session. Un script malveillant peut essayer d’injecter un identifiant de session (PHPSESSID
) connu, pour accéder à une session déjà ouverte. Si la session n’est jamais régénérée (session_regenerate_id(true)
), le risque augmente à chaque redirection.
On retrouve aussi les tentatives de contournement d’état, par exemple en forçant un Referer
ou une redirection intermédiaire, pour accéder à une page protégée sans passer par la vérification d’origine.
Enfin, certaines attaques combinent plusieurs approches : injection + bruteforce, ou analyse du temps de réponse pour déduire si un identifiant existe (attaque par temps de réponse).
Chacun de ces scénarios peut paraître abstrait. Mais une simple lecture des logs d’accès ou des journaux d’erreur montre à quel point ces tentatives sont quotidiennes, parfois dès les premières heures d’exposition d’un formulaire en ligne.
Et aujourd’hui ? Vers des protections dynamiques et progressives
Les protections d’hier suffisaient souvent à bloquer des attaques grossières. Aujourd’hui, les robots sont plus subtils : certains attendent un délai avant d’agir, d’autres imitent des requêtes AJAX ou injectent des en-têtes HTTP cohérents. Face à cette évolution, une protection efficace doit être contextuelle, modulaire, et capable de s’adapter au comportement de la requête.
Limiter les tentatives est une première réponse. Cela peut se faire par adresse IP, par session, ou en stockant temporairement les essais dans un fichier ou une base. Par exemple, si une même IP tente cinq connexions échouées en moins de deux minutes, on peut bloquer toute tentative ultérieure pour un temps donné :
if ($_SESSION['attempts'] >= 5 && (time() - $_SESSION['attempt_time'] < 120)) {
die("Trop de tentatives. Réessayez plus tard.");
}
On peut aussi décaler le traitement en ajoutant un délai croissant après chaque échec, ou simuler une réponse identique en cas d’erreur pour ne pas révéler si l’identifiant est correct.
Certains pièges, comme les honeypots ou les champs temporisés, gardent leur intérêt s’ils sont bien intégrés, voir Protection de base pour un formulaire. Mais pour une authentification, la vigilance doit porter sur l’analyse du comportement, pas seulement sur le formulaire. Un script peut par exemple refuser toute tentative venant d’un User-Agent
non reconnu, ou journaliser silencieusement les échecs répétitifs.
Enfin, pour les projets plus avancés, il est possible d’intégrer une double validation côté serveur : une première connexion avec mot de passe, suivie d’une validation externe ou différée (code par mail, session temporaire, etc.). On entre alors dans un modèle par paliers, qui fatigue les attaquants mais reste fluide pour l’utilisateur légitime.
Focus : les algorithmes de hachage, du MD5 à Argon2
Pendant longtemps, les développeurs stockaient les mots de passe en clair, ou utilisaient des fonctions comme md5()
ou sha1()
pour les « cacher ». Mais ces fonctions n’étaient pas faites pour cela. Elles produisent un résultat rapide, toujours identique, sans sel ni coût configurable. Résultat : il suffit de comparer des millions de hash pré-calculés pour retrouver un mot de passe courant. Certains projets ont ensuite migré vers SHA-2, notamment SHA-256 ou SHA-512, dans l’espoir de renforcer la sécurité :
$hash = hash('sha256', $password);
Mais même ces variantes restent trop rapides pour être utilisées seules dans la gestion de mots de passe. Leur principal défaut, comme MD5 ou SHA-1, est qu’elles ne gèrent pas le salage automatique ni le contrôle du temps de calcul. Elles sont utiles pour vérifier l’intégrité d’un fichier, pas pour protéger une identité.
C’est pourquoi PHP propose aujourd’hui des alternatives robustes via password_hash()
et password_verify()
, qui utilisent par défaut Bcrypt, un algorithme lent, salé, et résistant aux attaques par force brute. Le même mot de passe ne produit jamais deux fois le même résultat, et le « coût » (cost
) peut être ajusté pour ralentir volontairement la vérification.
$hash = password_hash($password, PASSWORD_DEFAULT);
// Plus tard, à la connexion :
if (password_verify($password_saisi, $hash)) { ... }
Mais depuis PHP 7.2, nous avons aussi accès à Argon2, recommandé par les experts en cryptographie. Il est plus souple, plus moderne, et gère mieux la mémoire. Il existe en deux variantes (argon2i
, argon2id
), la seconde étant souvent préférée pour sa polyvalence. On peut l’utiliser ainsi :
$hash = password_hash($password, PASSWORD_ARGON2ID);
Alors que choisir ? Si vous utilisez password_hash()
sans paramètre, PHP choisit la meilleure option disponible. Cela garantit la compatibilité tout en restant à jour. Et si l’algorithme évolue (ex : migration vers Argon2), vous pouvez re-générer le mot de passe au prochain login avec password_needs_rehash()
.
En revanche, évitez absolument d’utiliser md5()
ou sha1()
pour vos mots de passe. Même combinés à du « sel » manuel ($hash = md5($password.$salt)
), ils ne résistent pas à une attaque outillée. Ce sont des fonctions rapides, conçues à une époque où la sécurité n’était pas la priorité.
Un bon algorithme de hachage pour mot de passe doit être lent, variable et salé automatiquement. Et c’est exactement ce que password_hash()
garantit, sans configuration complexe.
Que contient vraiment un hash sécurisé ?
Quand on utilise password_hash()
, PHP ne renvoie pas juste une suite de caractères cryptiques. Il génère un hash enrichi, qui contient toutes les données nécessaires pour que password_verify()
puisse faire son travail, sans que vous ayez à stocker quoi que ce soit d’autre. Prenons un exemple concret :
$hash = password_hash('MonMotDePasse123', PASSWORD_BCRYPT);
echo $hash;
// Le résultat pourrait ressembler à ceci :
$2y$10$uLViTmc9YH5U3NgBfYVKAu5NK8XpJAtuMz0vTSWx0uEULeFiHG1sK
Décomposons-le ligne par ligne :
**$2y$**
: c’est le type d’algorithme utilisé, ici Bcrypt. Le préfixe$2y$
indique une version spécifique de Bcrypt adaptée à PHP.**10$**
: c’est le facteur de coût, aussi appelé cost. Plus ce chiffre est élevé, plus l’algorithme mettra de temps à produire le hash, ce qui ralentit aussi les attaques par force brute. Ici, 10 signifie que Bcrypt répétera une opération interne 2¹⁰ fois.**uLViTmc9YH5U3NgBfYVKAu**
: c’est le sel (salt), généré automatiquement par PHP. Il sert à rendre le hash unique, même si deux utilisateurs ont le même mot de passe.**5NK8XpJAtuMz0vTSWx0uEULeFiHG1sK**
: c’est le hash final, c’est-à-dire la version chiffrée du mot de passe combinée avec le sel, après passage par Bcrypt.
En résumé, cette chaîne encodée contient l’algorithme, le coût, le sel, et le hash lui-même, tous concaténés de façon standard. Cela permet à PHP de reconnaître comment le hash a été généré, et donc de le comparer automatiquement via :
password_verify($mot_de_passe, $hash_enregistré);
Mieux encore : si vous changez un jour d’algorithme (par exemple, vous passez à PASSWORD_ARGON2ID
), vous pourrez détecter les anciens hash avec password_needs_rehash()
et les mettre à jour à la volée, car l’ancien hash contient tout ce qu’il faut pour rester lisible.
C’est cette autonomie du hash — autoporté, interprétable et versionné — qui le rend si précieux dans les systèmes modernes. Il n’est pas juste un résultat crypté, c’est une empreinte intelligente, faite pour durer et évoluer.
Exemple progressif d’un script sécurisé
Pour conclure cette exploration, construisons ensemble un script PHP de connexion sécurisé, étape par étape, avec les protections essentielles en place. Il s’agit d’un exemple simplifié mais représentatif, que vous pourrez adapter selon votre contexte. Tout commence par la validation des données reçues :
session_start();
$username = $_POST['login']['username'] ?? '';
$password = $_POST['login']['password'] ?? '';
if (trim($username) === '' || trim($password) === '') {
die("Champs obligatoires manquants.");
}
On vérifie que les champs existent et ne sont pas vides. Aucun traitement ne commence sans cela. Ensuite, on applique une protection contre les tentatives répétées trop rapides :
if (!isset($_SESSION['login_attempts'])) {
$_SESSION['login_attempts'] = 0;
$_SESSION['first_attempt_time'] = time();
}
if ($_SESSION['login_attempts'] >= 5 && (time() - $_SESSION['first_attempt_time'] < 300)) {
die("Trop de tentatives. Réessayez plus tard.");
}
$_SESSION['login_attempts']++;
Ce bloc limite à 5 essais en 5 minutes. C’est simple, mais efficace contre les attaques en rafale. Vient ensuite la vérification de l’utilisateur en base de données, avec requête préparée :
require_once('__connect.php'); // Connexion à la BDD
$sth = $dbh->prepare("SELECT id, email, pwd_hash FROM users WHERE email = :email");
$sth->execute(['email' => $username]);
$user = $sth->fetch(PDO::FETCH_ASSOC);
if (!$user || !password_verify($password, $user['pwd_hash'])) {
die("Identifiants incorrects.");
}
La requête est protégée contre les injections SQL, et le mot de passe est comparé via password_verify()
. Aucun mot de passe en clair, aucun accès direct à la table sans filtre. Une fois l’utilisateur authentifié, on sécurise la session :
session_regenerate_id(true); // Évite la fixation d’ID
$_SESSION['connected'] = true;
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_mail'] = $user['email'];
Et pour aller plus loin, on peut tester si le mot de passe mérite d’être re-hashé avec un algorithme plus récent, comme Argon2. PHP propose pour cela la fonction password_needs_rehash()
, qui compare le hash existant avec les paramètres que vous souhaitez utiliser désormais.
Par défaut, password_hash()
utilise Bcrypt, mais vous pouvez passer à Argon2id pour renforcer encore la sécurité. Dans ce cas, vous testez comme ceci :
if (password_needs_rehash($user['pwd_hash'], PASSWORD_ARGON2ID)) {
$newHash = password_hash($password, PASSWORD_ARGON2ID);
$update = $dbh->prepare("UPDATE users SET pwd_hash = :hash WHERE id = :id");
$update->execute(['hash' => $newHash, 'id' => $user['id']]);
}
Cela permet de faire évoluer la qualité des hash au fil du temps, sans obliger l’utilisateur à changer son mot de passe. Le re-hash s’effectue automatiquement lors de la prochaine connexion réussie.
Et si vous préférez laisser PHP choisir l’algorithme « le plus adapté », vous pouvez continuer à utiliser PASSWORD_DEFAULT
. Mais sachez qu’actuellement, PASSWORD_DEFAULT
correspond toujours à Bcrypt, sauf changement dans une future version de PHP.
Ce script ne couvre pas tout — logs, droits, redirections — mais il offre une base claire et robuste, sans outil externe, et sans dépendre d’un framework. Il peut être enrichi par des vérifications comportementales, des jetons CSRF, ou un second facteur. Mais en l’état, il bloque déjà la majorité des attaques automatisées.
Conclusion
Sécuriser un formulaire de connexion, ce n’est pas empiler des bouts de code ou recopier des recettes trouvées en ligne. C’est comprendre ce que chaque couche protège, ce qu’elle laisse passer, et comment elles s’articulent entre elles.
PHP nous offre aujourd’hui des outils puissants : password_hash()
, password_verify()
, des algorithmes comme Bcrypt ou Argon2, des sessions paramétrables. Mais aucun de ces outils n’agit seul. Il faut les combiner, les adapter, les contextualiser pour bâtir une authentification qui résiste aux attaques les plus courantes — bruteforce, injection, vol de session, détournement de flux.
En construisant un script propre, lisible, évolutif, et en y intégrant des protections dynamiques, nous ne faisons pas que repousser les attaques : nous renforçons la confiance dans notre application, pour nous, pour nos utilisateurs, et pour tous ceux qui en dépendent.
Et si ce travail peut sembler excessif pour un « simple formulaire », rappelons que c’est souvent par cette seule porte que tout commence — ou que tout se perd.