Kryptex is an Elixir package for field-level encryption in Phoenix/Ecto apps.
AES-256-GCMfor authenticated encryption- random IV per encryption call
- key id embedded in ciphertext for key rotation safety
- encryption/decryption performed through an Ecto custom type
- a
Kryptex.Plugyou can mount in a Phoenix endpoint (or router pipeline) so misconfigured keys fail at request time and the active encryption key id is available onconn.assigns
Table of contents
- Why this approach
- Install
- Quickstart
- Key rotation example
- Configure your own model fields
- Postgres support out of the box
- Phoenix Plug (
Kryptex.Plug) - Development
- Security notes
- License
Why this approach
In this package, encryption happens in custom Ecto type callbacks (dump/load), and builds on top of that using Ecto.ParameterizedType so each schema can configure encrypted fields directly.
Install
Add dependency:
defp deps do
[
{:kryptex, "~> 0.1.0"}
]
endThen run:
mix deps.get
Quickstart
This walkthrough encrypts email, full_name, and metadata on a users table. Complete Install first, then follow the steps below.
Configure keys
Add a data-encryption key (DEK) to your environment, then wire the keyring in config/runtime.exs (recommended):
# generate a 32-byte key (base64) and export it, e.g. in .env or your deploy secrets
export KRYPTEX_DEK_1="$(mix run -e 'IO.puts(:crypto.strong_rand_bytes(32) |> Base.encode64())')"
config :kryptex,
keys: [
%{id: 1, key: System.fetch_env!("KRYPTEX_DEK_1")}
],
default_key_id: 1Keys can be either base64-encoded 32-byte values or raw 32-byte binaries. Generate one in an IEx session:
:crypto.strong_rand_bytes(32) |> Base.encode64()For multiple keys and rotation, see Key rotation example.
Migration
Encrypted values are stored as bytea in PostgreSQL. Use Kryptex.PostgresMigration when creating the table:
defmodule MyApp.Repo.Migrations.CreateUsers do
use Ecto.Migration
use Kryptex.PostgresMigration
def change do
create table(:users) do
add_encrypted :email, null: false
add_encrypted :full_name
add_encrypted :metadata
timestamps()
end
end
endRun the migration:
mix ecto.migrate
Schema
Declare encrypted fields on your schema with Kryptex.Schema:
defmodule MyApp.Accounts.User do
use Ecto.Schema
use Kryptex.Schema
schema "users" do
encrypted_field :email, :string
encrypted_field :full_name, :string
encrypted_field :metadata, :map
end
endAt runtime, Ecto encrypts on dump and decrypts on load—your application code works with normal Elixir values:
user =
%MyApp.Accounts.User{}
|> Ecto.Changeset.change(%{
email: "alice@example.com",
full_name: "Alice",
metadata: %{"plan" => "pro"}
})
|> MyApp.Repo.insert!()
MyApp.Repo.get!(MyApp.Accounts.User, user.id).email
# => "alice@example.com"Phoenix plug (optional)
In a Phoenix app, mount Kryptex.Plug on the endpoint so misconfigured keys fail at request time and conn.assigns.kryptex_key_id reflects the active write key:
# lib/my_app_web/endpoint.ex
plug Kryptex.PlugSee Phoenix Plug (Kryptex.Plug) for router pipelines and reading the assign.
Key rotation example
Kryptex writes new ciphertext with default_key_id, and reads old ciphertext
with the embedded key_id inside each stored payload.
That means rotation is usually a 3-step rollout:
Step 1: Add a new key and switch writes to it
Current config:
config :kryptex,
keys: [
%{id: 1, key: System.fetch_env!("KRYPTEX_DEK_1")},
%{id: 2, key: System.fetch_env!("KRYPTEX_DEK_2")}
],
default_key_id: 2Rotate by adding key 3, then switch the default:
config :kryptex,
keys: [
%{id: 1, key: System.fetch_env!("KRYPTEX_DEK_1")},
%{id: 2, key: System.fetch_env!("KRYPTEX_DEK_2")},
%{id: 3, key: System.fetch_env!("KRYPTEX_DEK_3")}
],
default_key_id: 3After deploy:
- new inserts/updates are encrypted with key
3 - existing rows encrypted with keys
1and2still decrypt correctly
Step 2: Re-encrypt old rows in the background (optional but recommended)
If you want all rows on the latest key, run a backfill job that:
- loads records in batches,
- rewrites encrypted fields with the same logical values,
- persists records so Kryptex re-dumps with the new
default_key_id.
Pseudo-flow:
for user <- MyApp.Repo.stream(MyApp.Accounts.User) do
user
|> Ecto.Changeset.change(%{
email: user.email,
full_name: user.full_name,
metadata: user.metadata
})
|> MyApp.Repo.update!()
endThis keeps plaintext unchanged while forcing re-encryption with key 3.
Step 3: Retire old keys only after verification
Before removing old keys:
- verify backfill completion
- verify no payloads remain for older key ids (from logs/DB sampling)
- verify reads in production for a safe window
Then remove the old key(s) from config:
config :kryptex,
keys: [
%{id: 3, key: System.fetch_env!("KRYPTEX_DEK_3")}
],
default_key_id: 3Important: if any row still has key_id 1 or 2, removing those keys will make
those rows undecryptable.
Configure your own model fields
Any schema can choose which fields are encrypted (see Quickstart for a full example).
defmodule MyApp.Accounts.User do
use Ecto.Schema
use Kryptex.Schema
schema "users" do
encrypted_field :email, :string
encrypted_field :full_name, :string
encrypted_field :metadata, :map
end
endYou can also skip the macro and declare directly:
field :email, Kryptex.EncryptedField, source_type: :stringPostgres support out of the box
Encrypted data is stored as :binary in Ecto, which maps to bytea in PostgreSQL. The Quickstart migration example is the usual starting point; details below:
Migration example:
defmodule MyApp.Repo.Migrations.CreateUsers do
use Ecto.Migration
use Kryptex.PostgresMigration
def change do
create table(:users) do
add_encrypted :email, null: false
add_encrypted :full_name
add_encrypted :metadata
timestamps()
end
end
endPhoenix Plug (Kryptex.Plug)
Field encryption is handled by Kryptex.EncryptedField in your Ecto schemas (see Quickstart).Kryptex.Plug is optional but useful in Phoenix: on each request it resolves the keyring (so missing or invalid :kryptex config surfaces immediately) and sets conn.assigns.kryptex_key_id to the id of the key used for new ciphertext (default_key_id).
It is a plain Plug (Plug.Conn), so it works anywhere in the Plug stack; in Phoenix the usual place is your endpoint, early in the plug chain.
Add the plug to a Phoenix app (endpoint)
In lib/my_app_web/endpoint.ex, after the early instrumentation plugs and before parsers/session/router is a common choice:
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
# ... existing plugs, e.g. RequestId, Telemetry ...
plug Kryptex.Plug
# ... Plug.Parsers, Plug.Session, MyAppWeb.Router, etc. ...
endEnsure config :kryptex, ... is loaded in config/runtime.exs (or equivalent) in all environments where the endpoint runs, so Kryptex.Keyring can read keys at runtime.
Add the plug to a router pipeline (optional)
If you only want the check on certain scopes (e.g. browser or API), you can plug it in a pipeline in lib/my_app_web/router.ex instead of (or in addition to) the endpoint. Putting it on the endpoint is usually simpler so every request sees the same keyring state.
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug Kryptex.Plug
# ... fetch_session, protect_from_forgery, etc.
end
scope "/", MyAppWeb do
pipe_through :browser
# ...
end
endReading the assign in plugs, controllers, or LiveView
After the plug runs, conn.assigns.kryptex_key_id is the integer key id used as “current” for encryption (same as Kryptex.Keyring.current_key_id/0). You can use it for logging, debugging, or passing metadata to telemetry.
Development
Run tests:
mix test
Run Credo:
mix credo --strict
Generate docs:
mix docs
Security notes
- Keep keys outside source control (environment variables or KMS).
- Rotate keys by appending a new key id and switching
default_key_id. - Existing rows remain decryptable because key id is preserved in payload.
- The GCM additional authenticated data (AAD) string is versioned with the library name; if you change it in a fork, existing ciphertext will not decrypt until you re-encrypt.
License
MIT