Blog
Envelope Encryption in Symfony 8.2: Lock the Key, Not Just the Data
Symfony 8.2 encrypts a Doctrine column with one attribute. The trick behind it is called envelope encryption: lock the data with one key, and lock that key with a second one that never leaves your key service. Here's how it works, with a playground to try it.
symfony php security 5 min read
By David Gorges
Managing Director
One attribute, and the column is locked
Here's the new feature in one snippet:
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\KeyManagement\Bridge\DoctrineOrm\Attribute\Encrypted;
#[ORM\Entity]
class Customer
{
// ...
#[ORM\Column(nullable: true)]
#[Encrypted(key: 'customer.contact')]
private ?string $phone = null;
}
That's all. In PHP, $phone is still a normal string. In the database, it's unreadable bytes. Symfony encrypts the value when it saves and decrypts it when it loads.
But encryption is only as good as the place where the key lives. If the key sits in .env next to the database password, whoever steals one probably has the other. That's the problem envelope encryption solves.
The idea: two keys
Picture a locked box:
- The data key locks the box. It encrypts your phone number.
- The master key locks the data key. It lives in a KMS, a key service like AWS KMS or HashiCorp Vault, and never leaves it.
So the locked box and its locked key are stored side by side, and only the KMS can open the key. Locking a key with another key has a name: wrapping. Here's what that looks like when you save a customer:
-
KMS to App data key ×2
The KMS hands out a fresh key
Twice, actually: once open, once locked with the master key.
-
App locked number
The app locks the phone number
With the open data key, right inside PHP (AES-256-GCM). The phone number never goes to the KMS.
-
App to Database both locked
Both locked things go into the row
The locked number and the locked key. The open data key is thrown away and never touches the disk.
Reading works the other way round: the app sends the locked key to the KMS, gets the open key back and unlocks the number.
Try it yourself. This is real encryption, in your browser:
This playground encrypts for real, in your browser. It needs JavaScript and a secure (https) connection.
Your app
nothing yet
KMS
- No calls yet
| id | phone | wrapped_key | Decrypt |
|---|
Click "Encrypt & save" twice with the same number: completely different bytes. Keep that in mind, it matters later.
What a thief gets
Now the part we're doing all this for.
-
Database to Thief SQL dump
A thief copies the database
A backup in the wrong place, an SQL injection. They now have every row.
-
Thief to KMS
Only the KMS can open the keys
Everything in the dump is locked, the keys included.
-
KMS to Thief denied
The KMS says no
No access to the KMS, no keys, no phone numbers.
Good to know
Searching gets harder. The same phone number never encrypts to the same bytes (remember the playground?), so WHERE phone = ? finds nothing. For exact lookups, Symfony adds a blind index: an extra column with a fingerprint of the value.
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\KeyManagement\BlindIndex\Email;
use Symfony\Component\KeyManagement\Bridge\DoctrineOrm\Attribute\BlindIndexed;
#[ORM\Entity]
class User
{
#[ORM\Column(type: 'encrypted_string')]
private string $email = '';
#[ORM\Column(length: 64)]
#[BlindIndexed('email', Email::class)]
private string $emailIndex = '';
// ...
}
// in a repository, look the row up by the digest of the value
$user = $this->findOneBy(['emailIndex' => $this->emailBlindIndex->of($email)]);
Fewer KMS calls. By default every value brings its own locked key, so every save and every load asks the KMS. With the key_management.store option, the locked data keys live in their own table and are shared, so the KMS is asked once per key instead of once per row.
Changing the master key is cheap. With that key table, one command re-locks all data keys with a new master key. Your customer rows stay untouched:
php bin/console key-management:rewrap-data-keys
What you can do now
-
List the columns that really hold personal data.
Phone, birth date, notes: what would hurt in a leak.
-
Use a real KMS in production.
sodium://is fine locally. In production, a master key in.envgets you nothing. -
Add a blind index to every column you search by.
Before the first query suddenly finds nothing.
-
Try it on a copy of your database first.
The Symfony posts don't say how existing plaintext rows get encrypted.
In one sentence: the database holds locked values and locked keys, and only the KMS decides who gets to open them.
Sources
6 sources
-
New in Symfony 8.2: Encrypted Doctrine Columns
symfony.com · Oct 15, 2026
-
New in Symfony 8.2: KeyManagement Component
symfony.com
-
[KeyManagement] Add the component (#64052)
github.com
-
#[Encrypted] attribute (#66806)
github.com
-
KeyManagement documentation (#22280)
github.com
-
Envelope encryption
cloud.google.com