Kryptex

cryptex

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

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:

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:

  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:

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

License

MIT