En bref : vous générez des jetons sûrs en PHP avec random_bytes(), par exemple 32 octets que vous convertissez en texte avec bin2hex() ou en Base64 pour URL. Pour des nombres aléatoires dans une plage, il y a random_int() ; pour des codes issus d'un jeu de caractères fixe, Random\Randomizer::getBytesFromString() depuis PHP 8.3. Vous stockez les jetons sous forme de hachage, limitez leur validité, ne les utilisez qu'une seule fois et les comparez avec hash_equals().
Un lien trop facile à deviner
« Mot de passe oublié ? » Un clic, et le système envoie un lien contenant un long code. Quiconque connaît le lien peut définir le mot de passe. C'est précisément pourquoi ce code doit être imprévisible. S'il ne l'est pas, un attaquant n'a besoin ni de la boîte aux lettres ni de l'ancien mot de passe, seulement d'un nombre suffisant d'essais.
Lors des audits d'applications PHP, on trouve étonnamment souvent des jetons construits à partir de l'heure, de l'identifiant utilisateur ou d'un simple générateur de nombres aléatoires. Ils paraissent longs et aléatoires, mais ne le sont pas. Il en va de même pour les clés d'API, les liens d'invitation, les liens de partage de documents, les codes de confirmation et les jetons CSRF.
Le risque est bien réel : un lien de réinitialisation prévisible permet de prendre le contrôle de tout compte dont l'adresse e-mail est connue, sans que le titulaire s'aperçoive de quoi que ce soit, jusqu'à ce qu'il ne puisse plus se connecter lui-même.
Depuis la version 7.0, PHP propose une solution simple et sûre. Il suffit de l'utiliser de manière cohérente.
random_bytes en PHP : d'où viennent les valeurs aléatoires sûres
Selon le manuel PHP, random_bytes() fournit des octets aléatoires cryptographiquement sûrs, qui conviennent aussi à des secrets de longue durée comme les clés. La fonction utilise la source d'aléa du système d'exploitation, par exemple getrandom() sous Linux, l'interface CNG sous Windows. Si elle ne trouve aucune source adaptée, elle lève depuis PHP 8.2 une Random\RandomException.
Le tableau montre quelles fonctions conviennent pour des secrets :
| Fonction | Adaptée aux jetons ? | Remarque |
|---|---|---|
random_bytes() |
oui | octets sûrs, depuis PHP 7.0 |
random_int() |
oui | nombre sûr dans une plage, depuis PHP 7.0 |
Random\Randomizer avec moteur Secure |
oui | selon le manuel, Secure est le moteur par défaut |
rand(), mt_rand() |
non | prévisibles, uniquement pour des usages sans lien avec la sécurité |
uniqid() |
non | basée sur le temps, conçue pour l'unicité |
md5(time()), sha1($id) |
non | entrée devinable, le hachage n'y change rien |
str_shuffle(), array_rand() |
non | n'utilisent pas de générateur sûr |
Générer des jetons : longueur et encodage
32 octets aléatoires correspondent à 256 bits et sont largement suffisants pour presque tous les usages. Pour la transmission dans des liens ou des en-têtes, le jeton doit être converti en texte. Deux variantes sont courantes :
<?php
declare(strict_types=1);
// Hex : 64 caractères, uniquement 0-9 et a-f
$tokenHex = bin2hex(random_bytes(32));
// Base64-URL : 43 caractères, sûr dans les URL sans autre encodage
$tokenUrl = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
// Nombre aléatoire dans une plage, par exemple pour une sélection
$index = random_int(0, 9);
// Dès PHP 8.3 : code numérique à six chiffres, p. ex. pour une confirmation par SMS
$randomizer = new \Random\Randomizer();
$code = $randomizer->getBytesFromString('0123456789', 6);
Les codes numériques courts ne sont acceptables que si le nombre d'essais est strictement limité et si le code expire au bout de quelques minutes. Pour les liens envoyés par e-mail, utilisez toujours le jeton long.
Pour les clés d'API que des partenaires ou vos propres systèmes utilisent durablement, deux compléments ont fait leurs preuves. Un court préfixe non secret placé avant la partie aléatoire, par exemple pour l'environnement ou la finalité, facilite l'identification dans les journaux et lors de découvertes dans des dépôts de code. Et la clé n'est affichée qu'une seule fois, à sa création ; ensuite, le système n'en connaît plus que le hachage. Si une clé est perdue, elle est remplacée, non restaurée.
Attention aux jetons faits maison qui contiennent des informations, par exemple l'identifiant utilisateur et l'heure d'expiration en Base64. L'encodage n'est pas une protection : n'importe qui peut lire et modifier de tels jetons. Si un jeton doit porter des données, il doit être signé avec une clé secrète, par exemple avec hash_hmac('sha256', ...), et la signature est vérifiée avec hash_equals(). Pour les cas plus complexes, des bibliothèques éprouvées sont préférables à des constructions maison.
Stocker les jetons, les vérifier et les faire expirer
Un jeton généré de façon sûre n'est que la moitié du travail. La façon dont l'application le traite est au moins aussi importante. Une démarche éprouvée pour les liens de réinitialisation et d'invitation :
- Ne stocker que le hachage : la base de données contient
hash('sha256', $token), le jeton lui-même n'est transmis à l'utilisateur que par le lien. - Limiter la validité : stocker aussi l'heure d'expiration et la vérifier lors de l'utilisation.
- Utiliser une seule fois : après une utilisation réussie, supprimer l'entrée ou la marquer comme consommée.
- Invalider en cas de changement : après un changement de mot de passe, supprimer tous les jetons de réinitialisation ouverts du compte.
- Comparer en temps constant : lorsqu'un jeton est comparé directement à une valeur stockée, utiliser
hash_equals(), avec la valeur stockée comme premier paramètre.
<?php
// Génération
$token = bin2hex(random_bytes(32));
$eintrag = [
'konto_id' => $kontoId,
'token_hash' => hash('sha256', $token),
'gueltig_bis'=> (new DateTimeImmutable('+30 minutes'))->format('Y-m-d H:i:s'),
];
// n'envoyer $token que dans le lien, stocker $eintrag
// Utilisation
$gesucht = hash('sha256', (string) ($_GET['token'] ?? ''));
// rechercher l'entrée par token_hash au moyen d'une requête préparée,
// vérifier l'expiration, puis supprimer immédiatement l'entrée
Limitez en outre le nombre de demandes de réinitialisation par compte et par période, afin que la fonction ne puisse pas être détournée pour un envoi massif d'e-mails. La réponse à une demande de réinitialisation ne devrait par ailleurs pas révéler si l'adresse indiquée possède un compte.
Un hachage rapide comme SHA-256 suffit ici, car le jeton lui-même est déjà long et aléatoire. Ce n'est pas valable pour les mots de passe choisis par des humains ; il faut là des algorithmes lents, comme l'explique Stocker les mots de passe en PHP en toute sécurité.
Les jetons n'ont pas leur place dans les fichiers journaux. Vérifiez si votre serveur web consigne des URL complètes avec leurs paramètres de requête, et si les rapports d'erreurs transmettent des données de requête.
Après un incident : renouveler jetons et clés
Quiconque a pu exécuter du code sur un serveur a généralement eu aussi accès aux jetons stockés, aux clés d'API et aux données de session. Si, après PHP2Shell ou un autre incident, une intrusion est soupçonnée, vous devriez donc renouveler non seulement les mots de passe, mais aussi les jetons et clés de longue durée : accès aux API, clés de signature pour les cookies ou les liens, accès aux services de paiement et d'expédition.
Les sessions sont un cas particulier : PHP génère lui-même les identifiants de session. La façon de sécuriser leurs réglages et de mettre fin aux sessions après un incident est décrite dans le guide Sécuriser les sessions en PHP. Pour les jetons de formulaire contre les requêtes falsifiées, voir Protection CSRF dans les applications PHP. D'autres outils intégrés sont présentés dans Fonctions de sécurité intégrées de PHP.
Comment PHP SOS aide-t-il concrètement ?
- Nous repérons dans votre application tous les endroits où des jetons, codes et clés sont générés, et remplaçons les sources non sûres.
- Nous vérifions le stockage, l'expiration et l'usage unique des liens de réinitialisation, d'invitation et de partage.
- Nous veillons à ce que les jetons n'atterrissent pas dans les journaux, les rapports d'erreurs ou les outils d'analyse.
- Après un incident, nous planifions avec vous le remplacement ordonné des jetons et des clés.
Nous proposons la vérification de logiciels PHP internes sous forme d'audit de sécurité et test d'intrusion.
Questions fréquentes
Pourquoi mt_rand est-il inadapté aux jetons ?
mt_rand et rand sont destinés aux statistiques et aux jeux, pas aux secrets. Leurs sorties peuvent, dans certaines conditions, être prédites. Pour tout ce qu'un attaquant ne doit pas pouvoir deviner, utilisez random_bytes ou random_int.
Quelle longueur un jeton doit-il avoir ?
Pour les liens de réinitialisation de mot de passe, les invitations ou les clés d'API, 32 octets aléatoires constituent un choix solide, soit 64 caractères en texte hexadécimal. 16 octets, donc 128 bits, sont une limite inférieure répandue.
uniqid est-il un jeton sûr ?
Non. uniqid repose sur l'heure actuelle et vise l'unicité, pas la confidentialité. Les combinaisons comme md5 de l'heure ou de l'identifiant utilisateur sont elles aussi prévisibles.
Dois-je chiffrer les jetons de réinitialisation dans la base de données ?
Stockez à la place du jeton un hachage de celui-ci, par exemple avec SHA-256. Quiconque lit la base de données ne peut alors pas fabriquer de liens valides. Comme le jeton lui-même est déjà long et aléatoire, il n'a pas besoin d'un algorithme lent comme pour les mots de passe.
Que se passe-t-il si le système ne trouve aucune source d'aléa ?
random_bytes et random_int lèvent depuis PHP 8.2 une Random\RandomException. Interceptez ce cas et interrompez l'opération, au lieu de vous rabattre sur une source plus faible.