🧪 Tutoriel pas à pas. Toutes les commandes et tous les fichiers nécessaires pour reproduire intégralement le projet de démonstration. Testé le 18 octobre 2026 avec Symfony 8.2.x-dev (avant la sortie de Symfony 8.2), PHP 8.4.5, Doctrine ORM 3.7.5 et Doctrine DBAL 4.5.0.
TL;DR
📌 Ce que vous allez constater :
- Un attribut
#[Encrypted]sur une propriété Doctrine suffit : la colonne devient unBLOBchiffré, et votre code continue de manipuler desstringet desDateTimeImmutable- Sur la démo, un dump de la table passe de 12 valeurs lisibles à 3 : seul le nom reste en clair, et c’est un choix
- La clé maître ne quitte jamais le KMS. En développement, un KMS local basé sur libsodium ; en production, AWS, Azure, Google Cloud ou Vault, en changeant une ligne de configuration
- Avec le store de clés de données, lire 200 valeurs chiffrées coûte 2 appels au KMS, au lieu de 200
- Le revers : une colonne chiffrée ne se recherche plus.
#[BlindIndexed]rétablit la recherche par égalité grâce à une empreinte- Le composant est expérimental, et Symfony 8.2 sort fin novembre 2026
Dépôt de démonstration : yoanbernabeu/symfony-encrypted-columns-demo
1. Le problème : votre base finira par fuiter
Un dump envoyé par erreur dans un ticket. Une sauvegarde déposée dans un bucket mal configuré. Un prestataire qui garde un accès en lecture. Une injection SQL qui permet de lire une table.
Dans tous ces cas, la question n’est pas de savoir comment la base est sortie, mais ce qu’on peut lire dedans. Et dans la plupart des applications, la réponse est simple : tout. Emails, numéros de téléphone, dates de naissance, en clair, colonne après colonne.
Jusqu’ici, chiffrer des colonnes dans une application Symfony voulait dire passer par un bundle tiers ou écrire son propre type Doctrine. Avec, très souvent, la clé de chiffrement rangée dans le .env, sur le même serveur que la base, pas de rotation de clé, et aucun moyen de retrouver une ligne par sa valeur.
Symfony 8.2 apporte une réponse officielle, en deux morceaux :
- le composant KeyManagement, contribué par Florent Morselli (#64052) : une API unique pour parler à n’importe quel gestionnaire de clés (AWS KMS, Azure Key Vault, Google Cloud KMS, HashiCorp Vault…) ;
- ses bridges Doctrine, dont l’intégration a été simplifiée par Nicolas Grekas (#66766, #66806) : un type DBAL qui chiffre une colonne, un attribut
#[Encrypted]pour l’ORM, et un attribut#[BlindIndexed]pour pouvoir chercher dans une colonne chiffrée.
⚠️ Composant expérimental. KeyManagement n’est pas couvert par la promesse de rétrocompatibilité de Symfony : son API peut changer d’une version mineure à l’autre. Cet article a été écrit sur la branche
8.2.x-dev, avant la sortie de Symfony 8.2, prévue fin novembre 2026. Les détails qui risquent de bouger d’ici là sont regroupés à la partie 10.
2. Trois notions avant de commencer
Un KMS (Key Management System) est un service dont le seul métier est de garder des clés de chiffrement. Votre application ne voit jamais la clé : elle envoie une donnée au KMS, qui la chiffre ou la déchiffre et la lui renvoie. AWS KMS, Azure Key Vault, Google Cloud KMS et HashiCorp Vault en sont les exemples les plus courants.
La clé maître est la clé que garde le KMS. Elle protège tout le reste. Votre application ne la connaît que par son identifiant : un alias, un ARN, une URL selon le fournisseur. Dans ce tutoriel, elle s’appellera simplement app-key.
Le modèle de menace, enfin, parce qu’il faut savoir contre quoi on se protège avant d’écrire une ligne de code :
| Le chiffrement des colonnes protège contre… | Il ne protège pas contre… |
|---|---|
| un dump de base qui fuite | une faille dans l’application elle-même |
| une sauvegarde exposée | un compte administrateur compromis |
| un accès en lecture directe à la base | un serveur volé avec ses secrets et son accès au KMS |
Autrement dit : le chiffrement protège la base, pas l’application. L’application déchiffre les données pour s’en servir. Quiconque prend son contrôle voit ce qu’elle voit.
Deux autres notions, la clé de données et le blind index, arriveront en chemin, au moment où on en aura besoin.
3. Le projet de démonstration
Prérequis
- PHP 8.4 ou supérieur, avec les extensions
sodium,openssletpdo_sqlite(présentes dans la plupart des installations) - Composer 2
- Facultatif : le Symfony CLI, et
sqlite3pour explorer la base
Récupérer le projet
Le point de départ est une petite application Symfony 8.2 : un carnet de clients, stocké dans SQLite. Plutôt que de vous faire recopier ses fichiers, clonez-le sur la branche etape-0-depart :
git clone https://github.com/yoanbernabeu/symfony-encrypted-columns-demo.git
cd symfony-encrypted-columns-demo
git checkout etape-0-depart
composer install
php bin/console doctrine:migrations:migrate -n
php bin/console doctrine:fixtures:load -n
php -S 127.0.0.1:8000 -t public # ou : symfony serve
Ouvrez http://127.0.0.1:8000 :

La page est coupée en deux :
- à gauche, ce que voit l’application : les entités chargées par Doctrine ;
- à droite, ce que voit un pirate : le contenu brut de la table
customer, lu sans passer par Doctrine, tel qu’il sortirait d’un dump. Le compteur l’annonce : 12 valeurs lisibles.
En dessous, une recherche de client par email (essayez ada@example.com) et un formulaire d’ajout. Survoler une ligne d’un côté l’allume de l’autre.
Ce que contient le projet
Le dépôt a une branche par étape de ce tutoriel. Chacune contient l’état du code à la fin de l’étape :
| Branche | État du code |
|---|---|
etape-0-depart | l’application de départ, sans chiffrement |
etape-1-installation | KeyManagement installé et configuré, rien n’est encore chiffré |
etape-2-chiffrement | l’email, le téléphone et la date de naissance chiffrés |
etape-3-blind-index | la recherche par email rétablie (identique à main) |
Les fichiers se répartissent en deux familles, et la distinction compte pour la suite.
Le code de la fonctionnalité, celui que vous modifierez dans ce tutoriel, et que vous reproduirez dans vos propres projets :
src/Entity/Customer.php l'entité
src/Controller/CustomerController.php la page et la recherche
src/DataFixtures/AppFixtures.php trois clients de démonstration
config/packages/key_management.yaml (à partir de l'étape 1)
config/services.yaml (le blind index, à l'étape 3)
L’outil de démonstration, qui ne fait pas partie de la fonctionnalité : src/Twig/DemoExtension.php et les templates. Ils lisent la base sans passer par Doctrine ORM pour afficher ce qu’elle contient réellement. Vous n’en aurez pas besoin dans une vraie application, et vous n’aurez pas à y toucher : ils fonctionnent tels quels à toutes les étapes, et font apparaître d’eux-mêmes les éléments liés au chiffrement quand ils existent.
L’entité de départ est une entité Doctrine tout ce qu’il y a de plus classique :
src/Entity/Customer.php
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Customer
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
public ?int $id = null;
#[ORM\Column(length: 255)]
public string $name;
#[ORM\Column(length: 255)]
public string $email;
#[ORM\Column(nullable: true)]
public ?string $phone = null;
#[ORM\Column(nullable: true)]
public ?\DateTimeImmutable $birthDate = null;
}
Et le contrôleur, tout aussi simple : une page qui liste les clients et cherche par email, et une action d’ajout.
src/Controller/CustomerController.php
<?php
namespace App\Controller;
use App\Entity\Customer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route;
class CustomerController extends AbstractController
{
#[Route('/', name: 'customer_index')]
public function index(EntityManagerInterface $em, #[MapQueryParameter] string $email = ''): Response
{
$repository = $em->getRepository(Customer::class);
return $this->render('customer/index.html.twig', [
'customers' => $repository->findAll(),
'email' => $email,
'found' => '' !== $email ? $repository->findOneBy(['email' => $email]) : null,
]);
}
#[Route('/customers', name: 'customer_new', methods: ['POST'])]
public function new(Request $request, EntityManagerInterface $em): Response
{
$customer = new Customer();
$customer->name = $request->request->getString('name');
$customer->email = $request->request->getString('email');
$customer->phone = $request->request->getString('phone') ?: null;
$customer->birthDate = ($birthDate = $request->request->getString('birthDate')) ? new \DateTimeImmutable($birthDate) : null;
$em->persist($customer);
$em->flush();
return $this->redirectToRoute('customer_index');
}
}
Le constat
L’interface est parlante, mais rien ne vous oblige à la croire sur parole. Avec SQLite, la base est un simple fichier : la voler, c’est le copier. Cherchons-y l’email d’Ada :
grep -a -c "ada@example.com" var/data.db
1
L’email est dans le fichier, en toutes lettres. Le dump de la table le confirme :
sqlite3 var/data.db ".dump customer"
INSERT INTO customer VALUES(1,'Ada Lovelace','ada@example.com','+33 6 12 34 56 78','1815-12-10 00:00:00');
INSERT INTO customer VALUES(2,'Alan Turing','alan@example.com','+33 6 98 76 54 32','1912-06-23 00:00:00');
INSERT INTO customer VALUES(3,'Grace Hopper','grace@example.com','+33 7 11 22 33 44','1906-12-09 00:00:00');
C’est ce que nous allons changer.
4. Étape 1 : installer KeyManagement
Trois paquets, tous en @dev tant que Symfony 8.2 n’est pas sorti :
composer require symfony/key-management:^8.2@dev \
symfony/doctrine-dbal-key-management:^8.2@dev \
symfony/doctrine-orm-key-management:^8.2@dev
| Paquet | Rôle |
|---|---|
symfony/key-management | le composant : l’API commune à tous les KMS, le chiffrement par enveloppe, les KMS locaux, le bundle et les commandes |
symfony/doctrine-dbal-key-management | le type DBAL EncryptedType et le store de clés de données |
symfony/doctrine-orm-key-management | les attributs #[Encrypted] et #[BlindIndexed] |
Enregistrer le bundle
Au moment où j’écris, aucune recipe Flex n’existe pour ce composant : il faut enregistrer le bundle à la main, à la fin de config/bundles.php.
config/bundles.php
<?php
return [
// ... les bundles existants
Symfony\Component\KeyManagement\KeyManagementBundle::class => ['all' => true],
];
Générer une clé maître locale
En production, la clé maître vit dans un vrai KMS. En développement, le composant fournit un KMS local basé sur libsodium, la bibliothèque de cryptographie intégrée à PHP depuis la version 7.2. Il fait exactement ce que fait un vrai KMS, mais la clé maître lui est fournie par la configuration. Il nous faut donc une clé :
echo "DEV_KMS_KEY=$(php -r 'echo sodium_bin2base64(sodium_crypto_aead_xchacha20poly1305_ietf_keygen(), SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);')" > .env.local
32 octets aléatoires, encodés en base64url, écrits dans .env.local, qui n’est jamais commité. Pour documenter la variable, déclarez-la vide dans .env :
.env (à ajouter à la fin)
###> symfony/key-management ###
# Clé maître du KMS local Sodium (dev uniquement), à générer dans .env.local
DEV_KMS_KEY=
###< symfony/key-management ###
À savoir : avec le KMS local, la clé maître est sur le même serveur que l’application. Le chiffrement reste réel, et protège toujours contre un dump qui fuite seul. Mais quelqu’un qui prend le serveur prend aussi la clé. C’est parfait pour développer, pas pour la production.
Configurer le composant
config/packages/key_management.yaml
key_management:
clients:
# En dev : un KMS local, la clé maître est dans .env.local
# En prod : un vrai KMS, par exemple 'aws-kms://default?region=eu-west-1'
default: 'sodium://?keys[app-key]=%env(DEV_KMS_KEY)%'
store:
key_id: app-key
clients déclare les KMS que l’application peut utiliser, chacun par un DSN. Ici, un seul client, default, qui pointe vers le KMS local Sodium et y déclare une clé maître nommée app-key. Passer en production consistera à changer ce DSN, et rien d’autre (on y revient à la partie 9).
store active le stockage des clés de données en base, avec app-key comme clé maître. C’est un choix qui a des conséquences importantes sur les performances et sur le sens de l’attribut qu’on va écrire juste après : on le détaillera à la partie 6, une fois qu’il y aura quelque chose à regarder.
Rechargez la page : rien n’a changé. Le composant est installé, mais aucune colonne n’est encore chiffrée.
📦 État du code à la fin de cette étape : branche
etape-1-installation· voir les changements de cette étape
5. Étape 2 : chiffrer les colonnes avec #[Encrypted]
Un attribut par colonne
On chiffre l’email, le téléphone et la date de naissance. Le nom reste en clair : on y reviendra.
src/Entity/Customer.php
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\KeyManagement\Bridge\DoctrineOrm\Attribute\Encrypted;
#[ORM\Entity]
class Customer
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
public ?int $id = null;
#[ORM\Column(length: 255)]
public string $name;
#[ORM\Column(length: 255)]
#[Encrypted(key: 'customer.contact')]
public string $email;
#[ORM\Column(nullable: true)]
#[Encrypted(key: 'customer.contact')]
public ?string $phone = null;
#[ORM\Column(nullable: true)]
#[Encrypted(key: 'customer.identity')]
public ?\DateTimeImmutable $birthDate = null;
}
C’est tout. Pas de type Doctrine à déclarer, pas de listener, pas de service.
⚠️ Malgré son nom,
keyn’est pas une clé. Avec lestoreconfiguré à l’étape précédente, c’est le nom d’un groupe de valeurs qui partageront la même clé de données : ici,customer.contactpour les coordonnées etcustomer.identitypour la date de naissance. La vraie clé reste dans le KMS. Sansstore, en revanche,keydésigne directement la clé maître du KMS :#[Encrypted(key: 'customer.contact')]échouerait alors avecKey "customer.contact" was not found in the KMS.et il faudrait écrirekey: 'app-key'. L’exemple de l’annonce officielle utilise des noms de groupe : il suppose donc unstore.
Ce que fait l’attribut, au moment où Symfony compile le conteneur : il repère les propriétés marquées #[Encrypted] dans les entités des répertoires qu’il scanne pour les services (src/Entity/ par défaut), et enregistre un type Doctrine chiffré pour chaque combinaison de type et de key. Doctrine utilise ensuite ce type à la place de string ou de datetime_immutable :
- à l’écriture, la valeur passe d’abord par le type d’origine (une date devient du texte, par exemple), puis elle est chiffrée ;
- à la lecture, elle est déchiffrée, puis rendue au type d’origine. Votre code continue de manipuler une
stringet unDateTimeImmutable.
Une entité mappée en XML, ou placée hors des répertoires scannés, échappe à cette découverte. Ses types se déclarent alors dans la configuration, et l’attribut les réutilise :
key_management: doctrine_dbal: types: encrypted_phone: { type: string, key: customer.contact }
La migration
php bin/console doctrine:migrations:diff
Voici ce qu’elle contient (SQLite, qui ne sait pas modifier le type d’une colonne, recrée la table au passage) :
CREATE TABLE key_management_data_keys (id BLOB NOT NULL, scope VARCHAR(191) NOT NULL, key_material BLOB NOT NULL, master_key_id VARCHAR(255) NOT NULL, client VARCHAR(64) NOT NULL, binding BLOB NOT NULL, PRIMARY KEY (id));
CREATE INDEX key_management_data_keys_scope_idx ON key_management_data_keys (scope, id);
-- ...
CREATE TABLE customer (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, email BLOB NOT NULL, phone BLOB DEFAULT NULL, birth_date BLOB DEFAULT NULL);
Deux choses à remarquer :
- les trois colonnes chiffrées deviennent des
BLOB, y comprisemail, déclarée aveclength: 255. C’est volontaire : cette longueur décrit la valeur en clair, et la valeur chiffrée est plus longue. Une colonne dimensionnée sur la valeur en clair tronquerait le chiffré, et le rendrait illisible ; - une table
key_management_data_keysapparaît. C’est celle dustore. On la regardera de près à la partie suivante.
Appliquez la migration, puis rechargez la page :
php bin/console doctrine:migrations:migrate -n
Stored value is not a valid KeyManagement envelope. (500 Internal Server Error)
C’est normal, et c’est la première leçon : chiffrer une colonne ne chiffre pas ce qu’elle contient déjà. La migration a recopié les trois clients en clair dans les nouvelles colonnes BLOB. Doctrine essaie maintenant de les déchiffrer, et refuse des données qui n’ont jamais été chiffrées.
Ici, rechargeons simplement les fixtures, qui réécrivent les trois clients en passant par Doctrine, donc chiffrés :
php bin/console doctrine:fixtures:load -n
Sur un vrai projet, il faut migrer les données existantes : relire chaque ligne en clair, et la réécrire à travers Doctrine. Et ne pas oublier que les anciennes sauvegardes, elles, contiennent toujours les données en clair.
Le résultat

- À gauche, rien n’a changé. L’application affiche les mêmes clients, les mêmes emails, les mêmes dates. Seuls de petits cadenas sont apparus dans les en-têtes des colonnes chiffrées.
- À droite, le compteur est passé à 3 valeurs lisibles et 9 chiffrées. L’email, le téléphone et la date de naissance ne sont plus que des octets (la page n’en affiche que les quatre derniers).
- Le nom est resté en rouge, parce qu’on ne l’a pas chiffré. C’est un choix : on ne chiffre que ce qui est sensible, et chaque colonne chiffrée a un coût, qu’on va découvrir.
Vérifions dans le fichier :
grep -a -c "ada@example.com" var/data.db
0
L’email d’Ada n’est plus nulle part dans la base. Le dump montre ce qu’un pirate obtiendrait désormais :
sqlite3 var/data.db ".dump customer"
INSERT INTO customer VALUES(1,'Ada Lovelace',X'02001001a14f123dec7203b9b103d9cc74f69653122b26d24a…',X'02001001a14f123dec…',X'…');
Un dernier détail, qui aura son importance : le même email chiffré deux fois ne donne jamais le même résultat. Chaque chiffrement utilise une valeur aléatoire, si bien que deux clients avec le même numéro de téléphone auraient deux chiffrés différents en base. On verra à la partie 7 ce que ça implique.
6. Sous le capot : clés de données et store
Un panneau est apparu sous le précédent : les clés de données.

Le chiffrement par enveloppe
La clé maître ne chiffre jamais directement vos données. Elle ne sort pas du KMS, et lui envoyer chaque valeur à chiffrer serait lent, coûteux, et limité en taille. Le composant procède autrement :
- il demande au KMS une clé de données : une clé aléatoire, que le KMS lui renvoie deux fois, en clair et chiffrée par la clé maître ;
- il chiffre vos valeurs localement, en AES-256-GCM, avec la clé de données en clair ;
- il oublie la clé en clair, et ne conserve que sa version chiffrée.
Pour relire une valeur, il fait le chemin inverse : il demande au KMS de déchiffrer la clé de données, puis déchiffre la valeur localement. C’est ce qu’on appelle le chiffrement par enveloppe : la valeur est scellée par une clé de données, elle-même scellée par la clé maître.
Le store : une clé de données par groupe
Reste à savoir où ranger les clés de données. Deux options :
- sans
store, chaque valeur emporte sa propre clé de données, chiffrée, avec elle. Chaque valeur lue demande donc un appel au KMS ; - avec
store, les clés de données sont rangées dans la tablekey_management_data_keys, une par groupe, et chaque valeur n’emporte qu’une référence de 16 octets vers sa clé. L’application ouvre la clé une fois auprès du KMS, puis chiffre et déchiffre toutes les valeurs du groupe localement.
C’est le panneau ci-dessus. La même table, en ligne de commande :
sqlite3 var/data.db "SELECT scope, master_key_id FROM key_management_data_keys"
customer.contact|app-key
customer.identity|app-key
La colonne scope, c’est le key de l’attribut #[Encrypted]. Deux groupes, deux clés de données : customer.contact protège les emails et les téléphones (6 valeurs), customer.identity les dates de naissance (3 valeurs). Survolez une clé dans la page : les valeurs qu’elle protège s’allument dans le panneau du pirate. Et la colonne key_material est elle-même chiffrée : quelqu’un qui vole la base vole des clés qu’il ne peut pas ouvrir.
La différence se mesure. Sur 100 clients avec deux colonnes chiffrées dans deux groupes, soit 200 valeurs, j’ai compté les appels au KMS :
| Écrire 200 valeurs | Relire 200 valeurs | |
|---|---|---|
sans store | 200 appels | 200 appels |
avec store | 2 appels | 2 appels |
Avec le KMS local, la différence ne se sent pas. Avec un KMS distant, à quelques millisecondes et souvent quelques fractions de centime par appel, c’est la différence entre une page utilisable et une page qui ne l’est pas. Avec un store, la page affiche d’ailleurs le compte : 9 valeurs chiffrées → 2 clés de données → 2 appels au KMS par requête.
À savoir : les clés de données ouvertes ne sont gardées en mémoire que le temps d’une requête. Le composant les efface entre deux requêtes, y compris dans un worker longue durée comme FrankenPHP. Chaque requête qui lit des données chiffrées fait donc au moins un appel au KMS. C’est voulu : pas de clé en clair qui traîne en mémoire plus longtemps que nécessaire.
La rotation des clés de données
Une clé de données ne sert pas éternellement. Le store en change automatiquement, tous les 30 jours, sans tâche planifiée ni commande à lancer :
- à chaque écriture, il prend la clé la plus récente du groupe ;
- il regarde son âge, sans colonne de date : l’identifiant de la clé est un UUIDv7, qui contient sa date de création ;
- si elle a plus de 30 jours, il demande une nouvelle clé au KMS, l’enregistre et s’en sert désormais ;
- les anciennes clés ne sont jamais supprimées. Chaque valeur garde la référence de la clé qui l’a chiffrée, et se relit avec elle. Rien n’est rechiffré.
Sur un vrai projet, vous trouverez donc plusieurs lignes par groupe dans cette table, et c’est normal.
Pourquoi 30 jours ? Chaque chiffrement AES-GCM utilise une valeur aléatoire de 96 bits, et si la même valeur sort deux fois avec la même clé, la sécurité des deux messages s’effondre. Le NIST fixe la limite à 2³² chiffrements par clé, environ quatre milliards. Compter les chiffrements coûterait une écriture en base à chaque valeur : le composant utilise donc l’âge comme approximation. Trente jours restent sous la limite jusqu’à environ 1 650 écritures par seconde dans un même groupe. La durée se règle :
key_management:
store:
key_id: app-key
max_age: 2592000 # en secondes : 30 jours par défaut, null pour ne jamais changer
Cette rotation ne doit pas être confondue avec deux autres opérations :
| Qui s’en charge | Effet | |
|---|---|---|
| Rotation des clés de données | le composant, automatiquement | une nouvelle clé pour les nouvelles valeurs |
| Rotation de la clé maître | le KMS (par exemple une fois par an chez AWS, si on l’active) | transparente pour l’application |
Rewrap (key-management:rewrap-data-keys) | vous, à la main | rechiffre les clés de données sous une autre clé maître, voire chez un autre fournisseur, sans toucher à une seule valeur |
7. Le revers : la recherche ne marche plus
Cherchez ada@example.com dans la page.

Aucun client trouvé. Et surtout, aucune erreur : Doctrine a fait sa requête, la base a répondu « rien ». C’est le genre de bug qui passe inaperçu.
La ligne du dessous explique pourquoi. Pour exécuter findOneBy(['email' => 'ada@example.com']), Doctrine chiffre la valeur cherchée avec le même type que la colonne, puis la compare à ce qui est stocké. Mais le chiffrement est aléatoire : la valeur envoyée (…41 ff 00 d6 sur ma capture) ne ressemble pas à celle stockée pour Ada (…54 64 91 98). Rechargez la page plusieurs fois : la valeur envoyée change à chaque fois, la valeur stockée jamais.
On pourrait charger tous les clients, tout déchiffrer, et chercher en PHP. Avec trois clients, oui. Avec un million, non.
📦 État du code à la fin de cette étape : branche
etape-2-chiffrement· voir les changements de cette étape
8. Étape 3 : rechercher avec un blind index
Le principe
On ne peut pas comparer des valeurs chiffrées. On peut, en revanche, comparer des empreintes.
Une empreinte est une suite de 64 caractères calculée à partir de l’email avec HMAC-SHA256 et une clé secrète. Le même email donne toujours la même empreinte, et on ne peut pas retrouver l’email à partir d’elle. On la stocke dans une colonne voisine, email_index : c’est le blind index. Pour chercher Ada, on calcule l’empreinte de ada@example.com et on cherche cette empreinte. La base compare des empreintes sans jamais voir un email : d’où le « aveugle ».
Pourquoi pas un simple SHA-256 de l’email ? Parce qu’un pirate peut prendre une liste de millions d’adresses connues, calculer leur SHA-256 et comparer avec la colonne : il retrouverait les emails en quelques minutes. Avec HMAC et une clé secrète, sans la clé, il ne peut pas calculer les empreintes.
Générer la clé du blind index
Cette clé est une clé de données comme les autres, que le KMS génère et chiffre avec la clé maître :
php bin/console key-management:generate-data-key app-key
{"key_id":"app-key","length":32,"plaintext":"…","wrapped":"…"}
La commande affiche la clé deux fois : en clair (plaintext) et chiffrée par la clé maître (wrapped). Seule la valeur wrapped doit être conservée, et la clé en clair ne doit être copiée nulle part. Ajoutez-la à .env.local, ou utilisez cette ligne qui le fait pour vous :
php bin/console key-management:generate-data-key app-key | php -r '$j = json_decode(stream_get_contents(STDIN), true); echo "APP_INDEX_KEY=".$j["wrapped"]."\n";' >> .env.local
Et documentez la variable dans .env, à côté de la précédente :
.env
###> symfony/key-management ###
# Clé maître du KMS local Sodium (dev uniquement), à générer dans .env.local
DEV_KMS_KEY=
# Clé du blind index, chiffrée par la clé maître : la valeur "wrapped" de key-management:generate-data-key
APP_INDEX_KEY=
###< symfony/key-management ###
Déclarer le blind index
config/services.yaml (à ajouter à la fin de la section services)
# Le blind index de l'email des clients
app.blind_index.customer_email:
class: Symfony\Component\KeyManagement\BlindIndex
arguments:
$kms: '@key_management.default'
$wrappedKey: !service
class: Symfony\Component\KeyManagement\Ciphertext
arguments: ['%env(base64:APP_INDEX_KEY)%', 'app-key']
$name: 'customer-email'
$projection: !service { class: Symfony\Component\KeyManagement\BlindIndex\Projection\Email }
tags:
- { name: key_management.blind_index, index: 'customer-email' }
Quatre arguments :
$kms: le client KMS déclaré à l’étape 1, qui déchiffrera la clé du blind index ;$wrappedKey: cette clé, chiffrée, avec l’identifiant de la clé maître qui l’a chiffrée ;$name: le nom du blind index, qui sert aussi à dériver sa clé. Deux colonnes indexées ont chacune le leur ;$projection: la façon de préparer la valeur avant de calculer l’empreinte.Emailretire les espaces autour de l’adresse et met le domaine en minuscules. Pas la partie avant l’@: la norme (RFC 5321) la considère sensible à la casse, et la projection la respecte.
Le tag key_management.blind_index déclare le blind index auprès du listener Doctrine qui remplira la colonne.
Ajouter la colonne d’empreinte
src/Entity/Customer.php
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\KeyManagement\Bridge\DoctrineOrm\Attribute\BlindIndexed;
use Symfony\Component\KeyManagement\Bridge\DoctrineOrm\Attribute\Encrypted;
#[ORM\Entity]
class Customer
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
public ?int $id = null;
#[ORM\Column(length: 255)]
public string $name;
#[ORM\Column(length: 255)]
#[Encrypted(key: 'customer.contact')]
public string $email;
#[ORM\Column(length: 64, nullable: true)]
#[BlindIndexed('email', 'customer-email')]
public ?string $emailIndex = null;
#[ORM\Column(nullable: true)]
#[Encrypted(key: 'customer.contact')]
public ?string $phone = null;
#[ORM\Column(nullable: true)]
#[Encrypted(key: 'customer.identity')]
public ?\DateTimeImmutable $birthDate = null;
}
#[BlindIndexed('email', 'customer-email')] se lit ainsi : « cette colonne contient l’empreinte de la propriété email, calculée par le blind index customer-email ». Un listener la remplit à chaque flush : vous n’écrivez jamais l’empreinte vous-même.
La colonne est nullable pour que la migration puisse l’ajouter à une table qui contient déjà des lignes. SQLite refuse d’ajouter une colonne NOT NULL sans valeur par défaut.
Chercher par l’empreinte
Le contrôleur change sur trois points :
- il reçoit le blind index par injection ;
- il calcule l’empreinte de l’email cherché, et cherche sur
emailIndex; - il normalise l’email (espaces et minuscules), à la recherche comme à l’enregistrement.
src/Controller/CustomerController.php
<?php
namespace App\Controller;
use App\Entity\Customer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\KeyManagement\BlindIndexInterface;
use Symfony\Component\Routing\Attribute\Route;
class CustomerController extends AbstractController
{
public function __construct(
#[Autowire(service: 'app.blind_index.customer_email')]
private BlindIndexInterface $emailIndex,
) {
}
#[Route('/', name: 'customer_index')]
public function index(EntityManagerInterface $em, #[MapQueryParameter] string $email = ''): Response
{
$repository = $em->getRepository(Customer::class);
$email = mb_strtolower(trim($email));
// L'empreinte de l'email cherché, calculée comme celle stockée en base
$tag = '' !== $email ? $this->emailIndex->of($email) : null;
return $this->render('customer/index.html.twig', [
'customers' => $repository->findAll(),
'email' => $email,
'found' => $tag ? $repository->findOneBy(['email' => $email]) : null,
'tag' => $tag,
'foundByIndex' => $tag ? $repository->findOneBy(['emailIndex' => $tag]) : null,
]);
}
#[Route('/customers', name: 'customer_new', methods: ['POST'])]
public function new(Request $request, EntityManagerInterface $em): Response
{
$customer = new Customer();
$customer->name = $request->request->getString('name');
// Normalisé à l'entrée, pour que la même adresse donne toujours la même empreinte
$customer->email = mb_strtolower(trim($request->request->getString('email')));
$customer->phone = $request->request->getString('phone') ?: null;
$customer->birthDate = ($birthDate = $request->request->getString('birthDate')) ? new \DateTimeImmutable($birthDate) : null;
$em->persist($customer);
$em->flush();
return $this->redirectToRoute('customer_index');
}
}
La recherche classique, found, est conservée pour la comparaison : c’est elle qui continue d’échouer.
La normalisation mérite qu’on s’y arrête. Une empreinte n’est identique que si le texte l’est, au caractère près. Or la plupart des fournisseurs de messagerie traitent Ada@example.com et ada@example.com comme la même adresse, alors que la projection Email, elle, les distingue. La solution n’est pas dans le blind index : elle consiste à normaliser l’adresse une fois, à l’entrée de l’application, et à stocker la forme normalisée.
Migrer et repartir d’une base propre
php bin/console doctrine:migrations:diff
ALTER TABLE customer ADD COLUMN email_index VARCHAR(64) DEFAULT NULL;
Les trois clients existants n’ont pas d’empreinte : le listener ne la calcule qu’au flush. Le plus simple est de repartir d’une base neuve :
rm -f var/data.db
php bin/console doctrine:migrations:migrate -n
php bin/console doctrine:fixtures:load -n
Pourquoi ne pas simplement recharger les fixtures ? Parce que
doctrine:fixtures:loadvide les tables de vos entités, mais paskey_management_data_keys. Les clés de données de l’étape 2 y resteraient, orphelines, et apparaîtraient dans le panneau avec « 0 valeur ». Sans conséquence, mais trompeur.
Le résultat

Une colonne email_index est apparue dans le panneau du pirate, en orange : une empreinte par client. Elle est lisible, mais elle ne révèle pas l’email.
Cherchez maintenant ADA@Example.com, en majuscules :

La recherche classique échoue toujours. La recherche par blind index trouve Ada, malgré les majuscules : le contrôleur a normalisé l’adresse, puis calculé son empreinte, 339f94a0…, qui est exactement celle stockée en base.
Ajoutez un client avec le formulaire, en saisissant son email en casse mixte, puis cherchez-le : son empreinte a été calculée au flush, et il est retrouvé.
Les limites du blind index
Elles sont importantes, et il faut les connaître avant de s’en servir :
- Égalité exacte uniquement. Pas de
LIKE, pas de tri, pas de recherche « commence par ». On retrouve une ligne par sa valeur exacte, rien de plus. - Les doublons se voient. Deux lignes avec le même email ont la même empreinte. Un pirate ne sait pas quel est cet email, mais il sait que ces lignes le partagent. Réservez donc le blind index aux valeurs quasi uniques, comme un email ou un numéro client, et jamais à un pays, un genre ou une ville : sur des valeurs peu nombreuses, compter les empreintes identiques suffit à deviner qui est qui.
- Seul l’ORM remplit l’index. Une ligne insérée par une requête DBAL, ou modifiée par un
UPDATEen masse, garde son ancienne empreinte, ou n’en a pas. - La clé du blind index ne doit jamais changer. Toutes les empreintes stockées en dépendent : en changer, c’est devoir les recalculer toutes.
📦 État du code à la fin de cette étape : branche
etape-3-blind-index· voir les changements de cette étape
9. Passer en production
Un vrai KMS, une ligne de configuration
Rien de ce qu’on a écrit ne dépend du KMS local. Les entités, le contrôleur, le blind index ne connaissent qu’un client et un identifiant de clé maître. Passer en production consiste à changer le DSN :
key_management:
clients:
default: '%env(KMS_DSN)%'
store:
key_id: '%env(KMS_KEY_ID)%'
Avec par exemple, côté AWS :
KMS_DSN=aws-kms://default?region=eu-west-1
KMS_KEY_ID=alias/app-key
Ou côté HashiCorp Vault, avec son moteur Transit :
KMS_DSN=hashicorp-vault-transit://TOKEN@vault.example.com:8200
Chaque fournisseur a son bridge, à installer avec Composer : symfony/aws-key-management, symfony/azure-keyvault-key-management, symfony/google-cloud-key-management, symfony/hashicorp-vault-key-management, et d’autres pour Scaleway ou KMIP. Chacun a son propre format de DSN, décrit dans son README.
⚠️ Les données chiffrées avec le KMS local ne se relisent pas avec un autre KMS : elles dépendent de la clé maître qui a chiffré leurs clés de données. Pour changer de clé maître sans tout réécrire, c’est le rôle de
key-management:rewrap-data-keys, évoqué à la partie 6.
Le KMS devient une dépendance critique
C’est la contrepartie à bien mesurer. J’ai pointé l’application vers un Vault injoignable :
Symfony\Component\KeyManagement\Exception\RuntimeException: Failed to reach Vault.
Toute page qui lit ou écrit une colonne chiffrée échoue, y compris la recherche par blind index, puisque sa clé est elle aussi protégée par le KMS. Les pages qui ne touchent aucune colonne chiffrée continuent de fonctionner, et les données en base restent intactes : elles redeviennent lisibles dès que le KMS revient. Si le KMS ne répond plus du tout, au lieu de refuser la connexion, chaque requête peut attendre plusieurs secondes avant d’échouer : d’après la documentation du bridge Vault, le client HTTP s’arrête par défaut après 5 secondes d’inactivité, et 10 secondes au total.
Il faut surtout distinguer deux situations :
- le KMS est indisponible : c’est une panne. Le service est dégradé, rien n’est perdu ;
- la clé maître est perdue : les données sont perdues, définitivement. Personne ne pourra plus les déchiffrer. Une clé maître se sauvegarde et se protège en conséquence.
Pour ne pas dépendre d’un seul fournisseur, le composant propose un client composite : plusieurs KMS derrière un seul client.
key_management:
clients:
aws: '%env(AWS_KMS_DSN)%'
vault: '%env(VAULT_KMS_DSN)%'
main:
members:
aws: 'alias/app-key'
vault: 'app-key'
default_client: main
store:
key_id: app-key
Chaque clé de données est alors chiffrée par chacun des KMS, et se relit à travers le premier qui répond. J’ai vérifié ce comportement avec deux KMS, dont l’un rendu injoignable :
| Situation | Résultat |
|---|---|
| lecture, le second KMS en panne | ✅ |
| lecture, le premier KMS en panne | ✅ (le composant passe au suivant) |
| création d’une clé de données, un KMS en panne | ❌ Failed to reach Vault. |
La création d’une nouvelle clé de données exige tous les KMS : une clé qui ne serait chiffrée que par un seul d’entre eux serait moins redondante que ce que promet la configuration, et le composant préfère refuser. Avec le store, ça n’arrive qu’à la création d’un nouveau groupe ou lors de la rotation : une panne d’un fournisseur ne bloque presque rien.
10. Les pièges rencontrés
Testés le 18 octobre 2026 sur Symfony 8.2.x-dev. Certains seront peut-être corrigés à la sortie de Symfony 8.2.
| Piège | Symptôme | Solution |
|---|---|---|
| Pas de recipe Flex | le bundle n’est pas chargé, la configuration key_management est inconnue | enregistrer KeyManagementBundle dans config/bundles.php |
key sans store | Key "customer.contact" was not found in the KMS. | configurer un store, ou utiliser le nom de la clé maître comme key |
| Données existantes en clair | Stored value is not a valid KeyManagement envelope. | migrer les données en les réécrivant à travers Doctrine |
$name du blind index non renseigné | Cannot autowire service "…": argument "$name" … you should configure its value explicitly. | renseigner $name dans la définition du service, même si le tag porte déjà le nom de l’index |
Colonne d’empreinte NOT NULL sur SQLite | Cannot add a NOT NULL column with default value NULL | la déclarer nullable: true |
| Recherche sensible à la casse | ADA@example.com ne retrouve pas ada@example.com | normaliser l’email à l’entrée de l’application |
| Fixtures rechargées | des clés de données orphelines dans key_management_data_keys | repartir d’une base neuve, ou vider cette table avant les fixtures |
generate-data-key | la clé s’affiche aussi en clair | ne conserver que la valeur wrapped |
Le README du bridge ORM indique que le tag
key_management.blind_indexfournit$nameau service. Sur la version testée, ce n’est pas le cas, d’où la quatrième ligne du tableau.
11. Ce qu’il faut retenir
Trois idées :
- Chiffrer une colonne Doctrine tient en un attribut.
#[Encrypted], une configuration de quelques lignes, et la colonne devient illisible pour quiconque lit la base. - L’application ne voit aucune différence, la base oui. Votre code manipule toujours des
stringet des dates. Seul un dump, une sauvegarde ou un accès direct à la base voit la différence, et c’est exactement le but. - Une valeur chiffrée ne se recherche plus. Le blind index rétablit la recherche par égalité, à condition d’en accepter les limites.
Le vocabulaire du tutoriel :
| Terme | En une phrase |
|---|---|
| KMS | le service qui garde la clé maître et chiffre ou déchiffre à la demande |
| Clé maître | la clé qui protège tout, et qui ne quitte jamais le KMS |
| Colonne chiffrée | une colonne qui ne contient plus que des octets, déchiffrés par Doctrine au chargement |
| Clé de données | la clé qui chiffre réellement les valeurs, elle-même chiffrée par la clé maître |
| Store | la table qui range les clés de données, une par groupe (key) |
| Rotation | le remplacement automatique d’une clé de données tous les 30 jours |
| Blind index | une colonne d’empreintes qui permet de retrouver une ligne sans déchiffrer |
| Empreinte | le résultat de HMAC-SHA256 avec une clé secrète : toujours identique pour une même valeur, et impossible à inverser |
Pour vérifier que tout est clair :
- Un pirate copie votre base. Que voit-il de la colonne
email? Et de la colonneemail_index? - Pourquoi
findOneBy(['email' => …])ne trouve plus rien, sans lever d’erreur ? - Pourquoi l’empreinte n’est-elle pas un simple SHA-256 de l’email ?
Et un exercice pour s’approprier le tout : ajoutez à l’entité un champ notes, de type text, chiffré dans son propre groupe. Combien de lignes compte alors la table key_management_data_keys ?
KeyManagement est encore expérimental, et la version finale de Symfony 8.2 apportera sans doute son lot d’ajustements. Mais l’essentiel est là, et il est déjà très solide : un chiffrement par enveloppe, des clés qui ne quittent jamais le KMS, une rotation sans intervention, et une recherche qui reste possible. Ce qui demandait hier un bundle tiers et beaucoup de vigilance devient un attribut.
Références
- Le dépôt de démonstration de cet article
- New in Symfony 8.2: KeyManagement Component
- New in Symfony 8.2: Encrypted Doctrine Columns
- Le composant KeyManagement
- La pull request du composant (#64052)
- La configuration des types chiffrés depuis le bundle (#66766)
- La documentation du composant, en cours de rédaction
Cet article a été écrit avec l’assistance d’une IA. Dans un harnais soigneusement réglé, évidemment.
Chargement des commentaires…