Encrypted
Authenticated, non-deterministic encryption for binary Ecto fields.
Encrypted is an Ecto.ParameterizedType backed by a PostgreSQL
bytea column. It encrypts every non-null value with AES-256-GCM, a fresh
96-bit IV, and a per-field key. Authentication binds the ciphertext to its
table and storage column. Moving a ciphertext to another table or column
therefore makes the load fail.
The package has one cipher and one envelope format. It uses OTP :crypto for
all cryptographic operations and has no third-party cryptography dependency.
It starts no process and stores no keys in ETS.
The package does not support deterministic encryption or encrypted queries.
Use ecto_blind_search beside an encrypted field when the application must
search it.
Installation
Add ecto_encrypted to mix.exs:
def deps do
[
{:ecto_encrypted, "~> 0.1"}
]
end
Generate a dedicated master key:
Encrypted.generate_key()
The result contains 64 lowercase hexadecimal digits. A configured key may instead contain the corresponding 32 raw bytes.
Runtime configuration
Configure a map of key IDs to master-key sources and select one active ID:
# config/runtime.exs
config :ecto_encrypted,
keys: %{
1 => {:system, "FIELD_ENCRYPTION_KEY_V1"},
2 => {:system, "FIELD_ENCRYPTION_KEY_V2"}
},
active_key_id: 2
Key IDs are integers from 0 through 255. Encryption always uses the active ID. Decryption reads the ID from the envelope and resolves that configured key. Keep an old key configured until every ciphertext has been rotated away from its ID.
Each key source may be:
:default, which readsconfig :ecto_encrypted, :master_key;- 32 raw bytes or exactly 64 hexadecimal digits;
{:system, "ENVIRONMENT_VARIABLE"};- a zero-arity function; or
- an
{module, function, arguments}tuple.
The type resolves keys when it encrypts or decrypts data. It does not resolve a
key while compiling the schema. A missing or malformed key raises
Encrypted.Error, and the error does not include key material.
Applications may declare :keys and :active_key_id on one field when that
field needs a separate ring:
field :medical_note, Encrypted,
keys: %{
7 => {:system, "MEDICAL_NOTE_KEY"}
},
active_key_id: 7,
redact: true
Prefer runtime application configuration for the common ring. A literal key in a schema becomes part of compiled application code.
Schema and migration
Store encrypted fields in nullable bytea columns:
def change do
alter table(:users) do
add :email, :binary, null: true
end
end
Declare the parameterized type and select its redaction behavior. Use
redact: true for the normal secure behavior:
defmodule MyApp.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, Encrypted, redact: true
timestamps()
end
def changeset(user, attributes) do
cast(user, attributes, [:email])
end
end
Each encrypted field must set redact: true or redact: false. A schema-wide
redaction policy does not replace this field option. This rule gives the same
behavior across all supported Ecto versions. Schema compilation fails when the
field omits the option.
Ecto holds plaintext in the loaded struct and changeset. redact: true keeps
the field out of the schema's derived Inspect implementation, but it does not
redact application logs that interpolate the plaintext directly.
redact: false explicitly allows the plaintext in derived Inspect output.
nil passes through as nil. The type never encrypts nil. Empty binaries
are encrypted and produce a valid envelope with an empty ciphertext segment.
Non-binary values fail the Ecto cast.
Embedded schemas are not supported. Their JSON representation cannot carry
arbitrary envelope bytes without a separate binary-encoding contract. Declaring
an encrypted field inside embedded_schema raises while the schema compiles.
Calling Ecto.Type.embedded_dump/3 for this type also raises. Store the encrypted
value in a database :binary field.
Every dump generates a new random IV. Dumping the same plaintext twice therefore produces different bytes. Equality predicates over the encrypted column cannot find a plaintext value.
Compose with ecto_blind_search
Encryption and querying use separate columns and separate packages. The changeset computes the blind index from plaintext before Ecto dumps the encrypted field:
defmodule MyApp.User do
use Ecto.Schema
use EctoBlindSearch
import Ecto.Changeset
schema "users" do
field :email, Encrypted, redact: true
blind_index :email,
normalize: &__MODULE__.normalize_email/1
timestamps()
end
def changeset(user, attributes) do
user
|> cast(attributes, [:email])
|> validate_required([:email])
|> put_blind_indexes()
|> blind_unique_constraint(:email)
end
def normalize_email(email) do
email
|> String.trim()
|> String.downcase()
|> String.normalize(:nfc)
end
end
The matching migration uses one bytea ciphertext column and one blind-index
column:
def change do
alter table(:users) do
add :email, :binary, null: true
add :email_bidx, :string, null: true
end
create unique_index(:users, [:email_bidx])
end
Query through the blind index:
Repo.get_by(MyApp.User, MyApp.User.blind_index(:email, "person@example.com"))
Use different master keys for encryption and blind indexes. The two packages also use different derivation domains, so a mistakenly shared master key does not produce the same field key. Domain separation does not make intentional key reuse acceptable. Separate master keys let operators rotate a leaked blind-index key without re-encrypting data, or rotate an encryption key without rebuilding indexes.
Envelope format
Every stored value uses the v1 binary envelope:
<<version::8, key_id::8, iv::binary-size(12),
tag::binary-size(16), ciphertext::binary>>
| Offset | Size | Value |
|---|---|---|
| 0 | 1 byte | version, currently 1 |
| 1 | 1 byte | key ID, 0..255 |
| 2 | 12 bytes | random GCM IV |
| 14 | 16 bytes | GCM authentication tag |
| 30 | remaining bytes | ciphertext |
The version byte is always first. Any future change to the cipher, key-ID width, tag, IV, derivation, or packing requires a new version. Unknown versions fail; the loader does not guess.
The GCM associated data consists of the version and key-ID bytes followed by a length-prefixed packing of:
["ecto_encrypted/aad/v1", table, storage_field]
The header bytes are included so changing the key ID also fails authentication. The packing starts with a little-endian 32-bit item count. Each item then has a little-endian 64-bit byte length followed by its bytes. Delimiter characters inside a table or field name therefore cannot make two contexts ambiguous.
FORMAT.md defines the byte-level contract, key derivation, and
pinned regression vector.
Table and column renames
The default context uses schema.__schema__(:source) and the field's database
source. Renaming either value changes the derived field key and associated
data. Existing ciphertext then fails authentication.
Preserve the old context during a rename:
schema "renamed_users" do
field :renamed_email, Encrypted,
key_context: {"users", "email"},
redact: true
end
key_context must name the table and storage column that originally produced
the ciphertext. It does not rename a database object.
Rotate encryption keys
Rotation changes the active write key and re-encrypts existing rows in place. Deploy in this order:
Add the new key ID to the runtime key ring. Keep every old key present.
Change
active_key_idto the new ID and deploy that configuration to every writer.Confirm that no old application instance can still write with the old active ID.
Re-encrypt the field:
Encrypted.Rotate.run(MyApp.Repo,MyApp.User,:email,batch_size: 100)Run the same command again. A return value of
0confirms that the scan found no row using an old key at that time.Remove the old key only after every field and every deployment scope that used its ID has completed the second scan.
The runner requires one primary key. It reads rows in primary-key order and
uses one transaction per batch. Each update includes the primary key and the
old ciphertext bytes in its WHERE clause. If another writer changes the
field after the runner reads it, the bytes no longer match and the runner
leaves the newer value intact. The next run examines that row again.
Scope a run with a query that contains only where clauses:
query = from(user in MyApp.User, where: user.tenant_id == ^tenant_id)
Encrypted.Rotate.run(
MyApp.Repo,
MyApp.User,
:email,
query: query,
batch_size: 50,
timeout: 30_000
)
The runner rejects joins, ordering, grouping, limits, offsets, explicit locks, and other structural expressions because they can change keyset pagination or the set of rows updated.
Failure semantics
Authentication failures are exceptions, not absent values. The type never
turns tampering into nil and never skips a damaged row.
Encrypted.Error.reason identifies the failure:
| Reason | Meaning and action |
|---|---|
:authentication_failed | The ciphertext, context, or key does not match. Restore trusted bytes and verify the configured context and key. |
:malformed_envelope | The stored value is shorter than the v1 header. Restore the original ciphertext. |
{:unknown_version, version} | The running code does not support the stored version. Deploy compatible code or restore a compatible value. |
{:unknown_key_id, id} | The key ring does not contain the envelope's ID. Restore that key before loading or rotating the field. |
:invalid_key | A resolved key is not 32 raw bytes or 64 hexadecimal digits. Correct the runtime secret. |
ArgumentError reports programmer misuse such as an unknown type option,
invalid key ID, unsupported rotation query shape, or invalid batch size.
Errors never include plaintext, key material, or a complete ciphertext. They may include a key ID or environment-variable name because operators need those identifiers to restore configuration.
Threat model
The package protects plaintext when an attacker obtains a database copy but not the master keys. AES-GCM also detects changes to the envelope, table context, or column context before returning plaintext.
Context binding prevents a database writer from moving a ciphertext to another table or column and obtaining a valid load. It does not bind the ciphertext to a row identity. An attacker may move a ciphertext between rows in the same column. The package also does not detect deletion or replay of an older valid ciphertext in the same column.
The envelope exposes the plaintext length because the ciphertext has the same length. It also exposes the format version and key ID. Database indexes, access logs, queries, and row relationships can expose additional metadata outside this type.
An attacker who obtains both the database and a master key can derive the affected per-field keys and decrypt those fields. An attacker who controls the running application can observe plaintext before encryption or after load. Application authorization, host security, backups, and log handling remain separate controls.
Random IVs make repeated plaintexts produce different ciphertexts. The package does not provide deterministic equality, frequency-hiding guarantees beyond that randomization, streaming encryption, or large-binary chunking.
Version 1 uses the random-IV construction from
NIST SP 800-38D, Sections 8.2.2 and 8.3.
NIST limits authenticated-encryption invocations to 2^32 for one derived
field key across all application instances. The package does not count
invocations. Rotate to new master-key material before one field reaches that
ceiling. Assigning a new key ID to the same master-key bytes does not reset it.
Per-field derivation gives each table and storage column an independent limit.
The package does not compress plaintext before encryption. Compression mixes attacker-controlled and secret input into a length side channel, as seen in CRIME-style attacks. Compress data only in a separate design that accounts for that leakage and defines its own envelope version.
Migrating from cloak_ecto
The v1 envelope cannot read a Cloak ciphertext. Migrate with two columns:
- Add a nullable
byteacolumn forEncrypted. - Deploy code that writes both the Cloak field and the new field.
- Backfill in batches by loading plaintext through the existing Cloak type and
writing that plaintext through
Encrypted. - Compare row counts and application reads, then switch all reads to the new field.
- Stop writing the Cloak column. Remove it only after rollback no longer depends on it.
Encrypted.Rotate cannot perform this migration because it only reads
this package's versioned envelope. A future adapter may add a read-only Cloak
format reader, but v1 does not contain one.
Non-goals
Version 1 deliberately excludes:
- deterministic encryption and query rewriting;
- KMS integration and envelope encryption;
- streaming and chunked large-binary formats;
- compression;
- a cipher-selection option; and
- Cloak wire-format compatibility.
Use a key resolver function or MFA when an application retrieves key bytes from an external secret service. The package itself does not own that service's lifecycle, credentials, retries, or cache.
Package name
The Hex package and OTP application use ecto_encrypted, which identifies the
Ecto integration. The public type uses Encrypted, which keeps schema field
declarations and error references short.