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 Co-authored with AI AI helped with research, structure and first drafts. David Gorges checked every fact, reworked the text and stands behind every sentence.

Managing Director

One attribute, and the column is locked

Here's the new feature in one snippet:

src/Entity/Customer.php
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:

Saving a phone number
  1. KMS to App data key ×2

    The KMS hands out a fresh key

    Twice, actually: once open, once locked with the master key.

  2. 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.

  3. 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:

Real AES-256-GCM, in your browser

This playground encrypts for real, in your browser. It needs JavaScript and a secure (https) connection.

Nothing leaves your browser. The master key is a WebCrypto key that not even this page's own code can read.

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.

The break-in
  1. Database to Thief SQL dump

    A thief copies the database

    A backup in the wrong place, an SQL injection. They now have every row.

  2. Thief to KMS open, please?

    Only the KMS can open the keys

    Everything in the dump is locked, the keys included.

  3. 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.

src/Entity/User.php
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:

Shell
php bin/console key-management:rewrap-data-keys

What you can do now

  1. List the columns that really hold personal data.

    Phone, birth date, notes: what would hurt in a leak.

  2. Use a real KMS in production.

    sodium:// is fine locally. In production, a master key in .env gets you nothing.

  3. Add a blind index to every column you search by.

    Before the first query suddenly finds nothing.

  4. 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
  1. Envelope encryption

    cloud.google.com

Contents