Symfony 8.2 New Published on

Encrypting the sensitive columns of an entity

#[Encrypted]: Doctrine encrypts a column before writing it and decrypts it when reading, with no custom type or listener.

Topics covered: DoctrineEncryption

New in Symfony 8.2 (in development). A phone number, a birth date, a social security number: some columns should not be readable in plain text in a backup lying around, or in a database copied to a developer's machine. Encrypting them used to take a third-party bundle, or a hand-written Doctrine type. Symfony 8.2 brings the KeyManagement component, and its Doctrine bridge encrypts a column with an attribute.

Before

final class EncryptedPhoneType extends Type
{
    public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
    {
        // a key to load, an IV to draw, openssl_encrypt(), the tag to store…
    }

    public function convertToPHPValue($value, AbstractPlatform $platform): ?string
    {
        // … and the reverse
    }
}

Then one such type per kind of value (text, date…), to declare in doctrine.yaml.

Since Symfony 8.2

use Symfony\Component\KeyManagement\Bridge\DoctrineOrm\Attribute\Encrypted;

#[ORM\Column(nullable: true)]
#[Encrypted(key: 'patient.contact')]
private ?string $phone = null;

Doctrine writes an encrypted, binary value to the database, then decrypts it when it reads the entity back. The property keeps its type: a string stays a string, and a date stays a DateTimeImmutable, because the column's type converts the value first, and the result is then encrypted. There is no type to declare and no listener to write: when it compiles the container, Symfony finds the marked properties in src/Entity/ and declares the Doctrine types they need.

Encryption is randomized: the same value, encrypted twice, never gives the same bytes. That is what keeps anyone from guessing who shares a number, but it is also why an encrypted column can no longer be searched with a WHERE or sorted with an ORDER BY. So you only encrypt what needs it.

The component uses envelope encryption: each value is encrypted by a data key, itself encrypted by a master key that never leaves the KMS (AWS KMS, Google Cloud KMS, Vault…). In this environment, the KMS is local (OpenSSL), and the data keys are stored in the database (key_management.store). The key parameter then names a scope: each scope has its own data key.

Your task

The clinic keeps its patients in the Patient entity. The /patients preview shows what the application reads and, under inDatabase, what the database really holds: for now, everything is in plain text.

  1. Encrypt the phone number under the key patient.contact.
  2. Encrypt the birth date under the key patient.identity.
  3. Leave the name in plain text: the list is sorted by name, in SQL.

The application must read exactly the same patients as before.

Where the feature comes from: the original pull request.