Skip to content

Encrypting Doctrine Columns

Edit this page

With symfony/doctrine-orm-key-management installed, you can encrypt a Doctrine column without writing any code. Mark the property and name the key it is encrypted under:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/Entity/User.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\KeyManagement\Bridge\DoctrineOrm\Attribute\Encrypted;

#[ORM\Entity]
class User
{
    #[ORM\Column]
    #[Encrypted(key: 'user.email')]
    private string $email = '';

    #[ORM\Column(type: 'date_immutable', nullable: true)]
    #[Encrypted(key: 'user.born_on')]
    private ?\DateTimeImmutable $bornOn = null;
}

Nothing else is needed. The bundle collects those columns while the container is built, and each Doctrine connection registers the types they need when the connection is created. key is the master key encrypting the values, or the scope of their data key when a store is configured.

When key is set, the bundle looks for these attributes in the entities, embeddables and mapped superclasses of the directories that your services are loaded from. With the default App\ resource, this includes src/Entity/.

The column keeps the type its mapping gives it, declared on #[ORM\Column] or guessed from the type of the property. The value goes through that type first and is encrypted afterwards, so a date is stored as an encrypted date, not as an encrypted string.

Warning

The KeyManagement component is experimental and is not covered by Symfony's Backward Compatibility Promise.

Note

The attribute requires a store or a default client: the one that default_client names, or the only client configured.

An encrypted column cannot be searched. See Searching Encrypted Data for how to find a row by a value the database holds only as ciphertext.

Omitting the Key

When a store is configured, key can be left out. Each column then gets its own scope, derived from the names of the schema, the table and the column that hold the values:

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/Entity/User.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\KeyManagement\Bridge\DoctrineOrm\Attribute\Encrypted;

#[ORM\Entity]
class User
{
    #[ORM\Column]
    #[Encrypted]
    private string $email = '';
}

The scope follows the physical names that Doctrine resolves, naming strategies, embedded prefixes and inherited columns included. Renaming the entity or the property keeps existing values readable only while the table and the column keep their names, for instance through the name option of #[ORM\Table] and #[ORM\Column]. Renaming the table or the column changes the scope. Before such a rename, set key to the scope that was generated for the old names. That scope is 'orm:'.hash('sha256', serialize([$schema, $table, $column])), where $schema is null for a table without a schema.

The scope doesn't contain the database, the connection or the tenant. Columns with the same names in two databases sharing one store therefore share their data keys, and a value copied from one to the other still decrypts. Give them explicit keys when that matters.

The first time an entity manager is created, the bundle finds the types of these columns, which loads the metadata of every mapped class. It then keeps them in the cache.key_management.encrypted_columns pool, a child of cache.system, so the next entity managers load no metadata. In debug mode, the types are found again for each entity manager, so that a change to the mapping is seen at once.

The bundle handles the columns that omit their key only when it finds at least one of them in the directories your services are loaded from. Otherwise, loading such a column fails as if no store was configured.

Declaring the Types Yourself

The attribute is a shortcut over named Doctrine types, which you can also declare directly. Use this when a column is mapped in XML or PHP rather than by attribute, or when you want to pick the type name.

You also need it when the bundle misses an #[Encrypted] column with a key, because its entity is mapped in XML, or is outside or excluded from the directories that your services are loaded from. The same happens when a custom typed field mapper makes the ORM use another type than the one guessed by the bundle. Loading the metadata of such an entity then fails, cache:warmup included. Declare the types of its columns and the attribute reuses them:

1
2
3
4
5
6
7
8
9
10
11
# config/packages/key_management.yaml
key_management:
    clients:
        app: '%env(KMS_DSN)%'
    store:
        key_id: 'alias/app-key'
    doctrine_dbal:
        types:
            app_user_email:
                type: string
                key: 'user.email'

Like the types of the attribute, the bundle registers these types each time DoctrineBundle creates a connection. They exist before the first query, whether the application runs through the front controller, the console (including the schema tool and migrations) or the tests. Without DoctrineBundle, the bundle registers them when the kernel boots. Registering them doesn't create the store or its database connection. Also like the attribute, they require a store or a default client; otherwise, the container fails to compile.

The name is then used like any Doctrine type:

1
2
3
4
5
6
7
8
9
10
11
// src/Entity/User.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class User
{
    #[ORM\Column(type: 'app_user_email')]
    private string $email = '';
}

A declared type and an #[Encrypted] column that share the same type and key are the same type: the attribute reuses the declared one instead of registering a second. Otherwise the attribute generates the name encrypted_<type>@<key>.

Both forms use the service that EnvelopeEncrypterInterface resolves to: the store-backed encrypter when a store is configured, and the envelope encrypter of the default client otherwise. A column that has to use another encrypter needs an EncryptedTypes service of its own. Declare it as a public service and call its register() method from the boot() method of your kernel. Such types can only wrap the types registered before, such as the built-in ones, since DoctrineBundle registers the types of its configuration later.

What the Column Holds

The stored value is the envelope, as bytes, in a binary column. Reading the row decrypts it and hands the plaintext back to the underlying type, so the property behaves exactly as it did before.

The column is always declared as an unbounded blob, and a length on the mapping is ignored on purpose. An envelope is larger than the value it holds, by 47 bytes with a store and by a hundred bytes or more without one, and a column sized after the plaintext would truncate it. On MySQL and MariaDB outside strict mode that truncation is a warning rather than an error, which would leave a row that no longer decrypts.

A column holding zero bytes is refused, like any other value that is not an envelope. Encrypting an empty string gives a non-empty envelope, so an empty column can only mean the value was never written through the type, and handing it to the underlying type unchecked would return '', false or 0 without authenticating anything.

Each value is authenticated together with the key of its column and the name of the Doctrine type it wraps, such as string. A value copied to a column with another key or another type name is refused. Changing the key or the type name of a column therefore means rewriting its values, even when both type names resolve to the same PHP class. Neither the name of the encrypted type nor the configuration of the wrapped type is authenticated: renaming the encrypted type doesn't require rewriting, and neither does changing the PHP class behind the wrapped type name, as long as the new class reads the old values.

Two columns using the same key and the same type name can read each other's values. Give them different keys or scopes to keep them apart.

Rotating the master key behind a key that keeps its name, such as an alias moved to a new key, doesn't require rewriting values. Changing the configured key (an alias, a key name, an ARN or a scope) does, even when the KMS can still read the old key.

When you build an EncryptedType yourself, pass the name of the wrapped type as its fourth argument. With null, the default, the class of the wrapped type is authenticated instead, so renaming or subclassing that class requires rewriting the values. To make a type you build and a configured one read each other's values, pass the same name as the type option of key_management.doctrine_dbal.types.

Copied, Deleted and Restored Values

An attacker who can write to your database can't read an encrypted value. Decryption fails when the attacker changes the value. They can still copy a value to another row of the same column, since a Doctrine type never sees the row it converts. They can set the column to NULL, which the type stores as is, without encrypting or authenticating it. And they can put back an older value of the same row.

When a value protects something, like a two-factor authentication secret, store the identity of the row inside the value and check it after decryption. Also give every row a value, so that a missing value is always an error, never a user without two-factor authentication:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// src/Entity/User.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\KeyManagement\Bridge\DoctrineOrm\Attribute\Encrypted;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
class User
{
    #[ORM\Id]
    #[ORM\Column(type: UuidType::NAME, unique: true)]
    private Uuid $id;

    /**
     * @var array{user: string, secret: ?string}
     */
    #[ORM\Column(type: 'json')]
    #[Encrypted(key: 'user.two_factor')]
    private array $twoFactor;

    public function __construct()
    {
        $this->id = Uuid::v7();
        $this->setTwoFactorSecret(null);
    }

    public function setTwoFactorSecret(?string $secret): void
    {
        $this->twoFactor = ['user' => (string) $this->id, 'secret' => $secret];
    }

    public function getTwoFactorSecret(): ?string
    {
        if ($this->twoFactor['user'] !== (string) $this->id) {
            throw new \RuntimeException('This secret belongs to another user.');
        }

        return $this->twoFactor['secret'];
    }
}

A user without two-factor authentication has a null secret inside the encrypted value, instead of a NULL column. Copying that value to the row of another user makes getTwoFactorSecret() throw an exception. The column isn't nullable, so the database refuses NULL. If a row contains NULL anyway, the property stays uninitialized, and reading the secret throws an error too.

Putting back an older value of the same row isn't detected, for example the value saved before the user turned two-factor authentication on. To detect it, you need data that the attacker can't change, stored outside the database. A version number stored next to the value, or inside it, doesn't help. The attacker puts the old version number back together with the old value.

Encrypting a Column That Already Holds Values

Adding #[Encrypted] to a column doesn't encrypt the values it already holds. The column becomes a blob, and reading a value that isn't an envelope fails. Encrypt them in a migration of your own: read each value with the type the column had, then write it back through the encrypted type.

Moving a Column to a Stored Data Key

Configure a store with read_self_contained: true. Keep the key and the type of the column, since both are authenticated with every value. The key then becomes the scope of the stored data keys, so don't leave it out at the same time. Values written from then on contain a reference to a stored data key, while older ones keep being read through the KMS. Doctrine writes a value again only when it changes, so rewrite the old ones with a script of your own that reads and writes each of them through the encrypted type. Then turn the option off: a self-contained payload carries its own wrapped data key and skips the scope and binding checks of the store, even though its envelope still authenticates the key and the type name of the column.

Re-wrapping those data keys later moves the whole column to another master key, or another provider, without touching a single row.

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version