cryptex

Kryptex is an Elixir package for field-level encryption in Phoenix/Ecto apps.

  • AES-256-GCM for 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.Plug you can mount in a Phoenix endpoint (or router pipeline) so misconfigured keys fail at request time and the active encryption key id is available on conn.assigns

Table of contents

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"}
  ]
end

Then 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: 1

Keys 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
end

Run 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
end

At 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.Plug

See 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: 2

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

After deploy:

  • new inserts/updates are encrypted with key 3
  • existing rows encrypted with keys 1 and 2 still decrypt correctly

If you want all rows on the latest key, run a backfill job that:

  1. loads records in batches,
  2. rewrites encrypted fields with the same logical values,
  3. 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!()
end

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

Important: 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
end

You can also skip the macro and declare directly:

field :email, Kryptex.EncryptedField, source_type: :string

Postgres 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
end

Phoenix 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. ...
end

Ensure 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
end

Reading 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