---
title: "Colonnes Doctrine chiffrées avec Symfony 8.2 : votre base peut fuiter, pas vos données"
excerpt: "Un dump de base de données qui fuite, et ce sont des emails, des téléphones et des dates de naissance en clair. Symfony 8.2 arrive avec le composant KeyManagement et un attribut #[Encrypted] qui chiffre vos colonnes Doctrine. Tutoriel pas à pas avec dépôt de démonstration : chiffrement, clés de données, KMS et recherche par blind index."
publishDate: 2026-10-18T00:00:00.000Z
tags: ["symfony", "symfony-8-2", "php", "doctrine", "securite", "chiffrement", "key-management", "kms", "blind-index", "rgpd", "tutorial"]
canonical: "https://yoandev.co/symfony-colonnes-doctrine-chiffrees"
---

*🧪 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 un `BLOB` chiffré, et votre code continue de manipuler des `string` et des `DateTimeImmutable`
> - 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](https://github.com/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](https://github.com/symfony/key-management)**, contribué par Florent Morselli ([#64052](https://github.com/symfony/symfony/pull/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](https://github.com/symfony/symfony/pull/66766), [#66806](https://github.com/symfony/symfony/pull/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](#10-les-pièges-rencontrés).

---

## 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`, `openssl` et `pdo_sqlite` (présentes dans la plupart des installations)
- Composer 2
- Facultatif : le [Symfony CLI](https://symfony.com/download), et `sqlite3` pour 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` :

```bash
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 de démonstration avant chiffrement : à gauche, ce que voit l'application ; à droite, le contenu brut de la table, où les 12 valeurs sont lisibles et signalées en rouge](./img/avant-chiffrement.webp)

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
<?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
<?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 :

```bash
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 :

```bash
sqlite3 var/data.db ".dump customer"
```

```sql
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 :

```bash
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
<?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é :

```bash
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)

```bash
###> 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`**

```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](#9-passer-en-production)).

**`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](#6-sous-le-capot--clés-de-données-et-store), 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`](https://github.com/yoanbernabeu/symfony-encrypted-columns-demo/tree/etape-1-installation) · [voir les changements de cette étape](https://github.com/yoanbernabeu/symfony-encrypted-columns-demo/compare/etape-0-depart...etape-1-installation)

---

## 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
<?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, `key` n'est pas une clé.** Avec le `store` configuré à 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.contact` pour les coordonnées et `customer.identity` pour la date de naissance. La vraie clé reste dans le KMS. Sans `store`, en revanche, `key` désigne directement la clé maître du KMS : `#[Encrypted(key: 'customer.contact')]` échouerait alors avec `Key "customer.contact" was not found in the KMS.` et il faudrait écrire `key: 'app-key'`. L'exemple de l'annonce officielle utilise des noms de groupe : il suppose donc un `store`.

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 `string` et un `DateTimeImmutable`.

> 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 :
>
> ```yaml
> key_management:
>     doctrine_dbal:
>         types:
>             encrypted_phone: { type: string, key: customer.contact }
> ```

### La migration

```bash
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) :

```sql
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 compris `email`, déclarée avec `length: 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_keys` apparaît.** C'est celle du `store`. On la regardera de près à la partie suivante.

Appliquez la migration, puis rechargez la page :

```bash
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 :

```bash
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

![La page après chiffrement : l'application affiche toujours les données en clair, mais la base ne contient plus que des octets pour l'email, le téléphone et la date de naissance, et un panneau des clés de données apparaît](./img/apres-chiffrement.webp)

- **À 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 :

```bash
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 :

```bash
sqlite3 var/data.db ".dump customer"
```

```sql
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 panneau des clés de données : deux lignes, customer.contact qui protège 6 valeurs et customer.identity qui en protège 3 ; au survol de customer.contact, les cellules email et téléphone s'allument dans le panneau du pirate](./img/cles-de-donnees.webp)

### 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 :

1. 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 ;
2. il chiffre vos valeurs **localement**, en AES-256-GCM, avec la clé de données en clair ;
3. 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 table `key_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 :

```bash
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 :

1. à chaque écriture, il prend la clé la plus récente du groupe ;
2. il regarde son âge, sans colonne de date : l'identifiant de la clé est un UUIDv7, qui contient sa date de création ;
3. si elle a plus de 30 jours, il demande une nouvelle clé au KMS, l'enregistre et s'en sert désormais ;
4. **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 :

```yaml
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.

![La recherche par email échoue : Doctrine envoie à la base une valeur chiffrée différente de celle stockée pour Ada Lovelace](./img/recherche-cassee.webp)

**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`](https://github.com/yoanbernabeu/symfony-encrypted-columns-demo/tree/etape-2-chiffrement) · [voir les changements de cette étape](https://github.com/yoanbernabeu/symfony-encrypted-columns-demo/compare/etape-1-installation...etape-2-chiffrement)

---

## 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 :

```bash
php bin/console key-management:generate-data-key app-key
```

```json
{"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 :

```bash
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`**

```bash
###> 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`)

```yaml
    # 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. `Email` retire 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
<?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 :

1. il reçoit le blind index par injection ;
2. il calcule l'empreinte de l'email cherché, et cherche sur `emailIndex` ;
3. il **normalise l'email** (espaces et minuscules), à la recherche comme à l'enregistrement.

**`src/Controller/CustomerController.php`**

```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

```bash
php bin/console doctrine:migrations:diff
```

```sql
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 :

```bash
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:load` vide les tables de vos entités, mais pas `key_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

![Le panneau du pirate avec la nouvelle colonne email_index : une empreinte par client, en orange](./img/blind-index-empreintes.webp)

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, mais la recherche par blind index retrouve Ada Lovelace grâce à son empreinte](./img/recherche-blind-index.webp)

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 :

1. **É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.
2. **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.
3. **Seul l'ORM remplit l'index.** Une ligne insérée par une requête DBAL, ou modifiée par un `UPDATE` en masse, garde son ancienne empreinte, ou n'en a pas.
4. **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`](https://github.com/yoanbernabeu/symfony-encrypted-columns-demo/tree/etape-3-blind-index) · [voir les changements de cette étape](https://github.com/yoanbernabeu/symfony-encrypted-columns-demo/compare/etape-2-chiffrement...etape-3-blind-index)

---

## 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 :

```yaml
key_management:
    clients:
        default: '%env(KMS_DSN)%'
    store:
        key_id: '%env(KMS_KEY_ID)%'
```

Avec par exemple, côté AWS :

```bash
KMS_DSN=aws-kms://default?region=eu-west-1
KMS_KEY_ID=alias/app-key
```

Ou côté HashiCorp Vault, avec son moteur Transit :

```bash
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.

```yaml
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_index` fournit `$name` au 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 :

1. **Chiffrer une colonne Doctrine tient en un attribut.** `#[Encrypted]`, une configuration de quelques lignes, et la colonne devient illisible pour quiconque lit la base.
2. **L'application ne voit aucune différence, la base oui.** Votre code manipule toujours des `string` et des dates. Seul un dump, une sauvegarde ou un accès direct à la base voit la différence, et c'est exactement le but.
3. **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 colonne `email_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](https://github.com/yoanbernabeu/symfony-encrypted-columns-demo)
- [New in Symfony 8.2: KeyManagement Component](https://symfony.com/blog/new-in-symfony-8-2-keymanagement-component)
- [New in Symfony 8.2: Encrypted Doctrine Columns](https://symfony.com/blog/new-in-symfony-8-2-encrypted-doctrine-columns)
- [Le composant KeyManagement](https://github.com/symfony/key-management)
- [La pull request du composant (#64052)](https://github.com/symfony/symfony/pull/64052)
- [La configuration des types chiffrés depuis le bundle (#66766)](https://github.com/symfony/symfony/pull/66766)
- [La documentation du composant, en cours de rédaction](https://github.com/symfony/symfony-docs/pull/22280)

---

<small>*Cet article a été écrit avec l'assistance d'une IA. Dans un harnais soigneusement réglé, évidemment.*</small>
