Quickstart: KMS in a Phoenix app

Copy Markdown View Source

This guide assumes you are an Elixir/Phoenix developer who wants field-level encryption in a normal Phoenix application.

It starts with a vanilla Phoenix app in example/phoenix_example, adds :kms as a dependency, configures KMS to start with the app, and encrypts one Ecto schema field through owner-scoped KMS aliases and AAD.

KMS uses SQLite by default. The Phoenix app can keep its normal PostgreSQL app database while KMS stores its own key tables in tmp/kms/kms_dev.sqlite3.

Working example in this repo:

example/phoenix_example

Evidence this example works:

cd example/phoenix_example
mix ecto.reset
mix test
# 7 tests, 0 failures

1. Start with Phoenix

Generate a normal Phoenix application:

mkdir -p example
cd example
mix phx.new phoenix_example --no-install
cd phoenix_example

The committed example was generated this way, then patched to act like a standalone app inside this repository.

2. Add KMS as a dependency

Add KMS to your application's mix.exs:

defp deps do
  [
    {:kms, "~> 0.1.0"},
    {:phoenix, "~> 1.8.1"},
    # ... normal Phoenix deps
  ]
end

The committed example/phoenix_example application uses {:kms, path: "../.."} so it exercises the repository checkout directly.

Start the KMS OTP application with the Phoenix app:

def application do
  [
    mod: {PhoenixExample.Application, []},
    extra_applications: [:logger, :runtime_tools, :kms]
  ]
end

The KMS OTP application starts:

  • the configured KMS repo, delegating to SQLite by default
  • KMS.Cache
  • session cleanup
  • optional fallback bootstrap

3. Configure Phoenix and KMS repos

The example uses two persistence stores:

  • PhoenixExample.Repo for application tables.
  • KMS SQLite repo for KMS key tables.

In config/config.exs:

config :phoenix_example,
  ecto_repos: [PhoenixExample.Repo, KMS.Repo.SQLite]

config :kms, ecto_repos: [KMS.Repo.SQLite]
config :kms, KMS.Repo, repo: KMS.Repo.SQLite
config :kms, :client, KMS.Client.Local
config :kms, :rmk_provider, KMS.RMK.Local
config :kms, KMS.RMK.Local, key_path: Path.expand("../priv/kms_rmk_#{config_env()}.key", __DIR__)

In config/dev.exs, keep Phoenix on PostgreSQL and KMS on SQLite:

config :phoenix_example, PhoenixExample.Repo,
  username: "postgres",
  password: "postgres",
  hostname: "localhost",
  database: "phoenix_example_dev"

config :kms, KMS.Repo.SQLite,
  database: Path.expand("../tmp/kms/kms_dev.sqlite3", __DIR__),
  journal_mode: :wal,
  foreign_keys: :on,
  busy_timeout: 5_000,
  pool_size: 10,
  priv: "priv/repo",
  migration_primary_key: false,
  migration_foreign_key: [type: :text],
  migration_timestamps: [type: :utc_datetime_usec]

KMS can use PostgreSQL too, but SQLite is the simplest embedded/default path. See Database configuration.

For production, configure a high-entropy KMS_SESSION_HASH_KEY, explicit RMK key path or external RMK provider, SQLite database backups, TLS, and crash dump policy. See Security and Root master keys.

4. Create an encrypted Ecto field

The example stores documents with encrypted body text:

defmodule PhoenixExample.Documents.Document do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: false}
  @foreign_key_type :binary_id

  schema "documents" do
    field :owner_id, :string
    field :title, :string
    field :encrypted_body, :string, redact: true
    field :body, :string, virtual: true, redact: true

    timestamps(type: :utc_datetime)
  end

  def changeset(document, attrs) do
    document
    |> cast(attrs, [:id, :owner_id, :title, :body])
    |> maybe_put_id()
    |> validate_required([:id, :owner_id, :title, :body])
    |> validate_length(:owner_id, min: 1, max: 255)
    |> validate_length(:title, min: 1, max: 255)
    |> encrypt_body()
    |> validate_required([:encrypted_body])
  end

  defp maybe_put_id(changeset) do
    case get_field(changeset, :id) do
      nil -> put_change(changeset, :id, Ecto.UUID.generate())
      _id -> changeset
    end
  end

  defp encrypt_body(%Ecto.Changeset{valid?: true} = changeset) do
    owner_id = get_field(changeset, :owner_id)
    document_id = get_field(changeset, :id)
    body = get_field(changeset, :body)

    with _kek <- ensure_alias!(owner_id),
         {:ok, encrypted_body} <- encrypt_body(body, owner_id, document_id) do
      put_change(changeset, :encrypted_body, encrypted_body)
    else
      {:error, reason} -> add_error(changeset, :body, "encryption failed: #{inspect(reason)}")
    end
  end

  defp encrypt_body(changeset), do: changeset

  def decrypt_body(%__MODULE__{encrypted_body: encrypted_body, owner_id: owner_id, id: document_id}) do
    KMS.decrypt_alias(encrypted_body, alias_name(owner_id), aad: aad(document_id))
  end

  def alias_name(owner_id), do: "owner:#{owner_id}"
  def aad(document_id), do: "document:#{document_id}/body"

  defp ensure_alias!(owner_id) do
    case KMS.create_alias(alias_name(owner_id)) do
      {:ok, kek} -> kek
      {:error, reason} -> raise "failed to ensure KMS alias: #{inspect(reason)}"
    end
  end

  defp encrypt_body(body, owner_id, document_id) do
    KMS.encrypt_alias(body, alias_name(owner_id), aad: aad(document_id))
  end
end

Migration:

def change do
  create table(:documents, primary_key: false) do
    add :id, :binary_id, primary_key: true
    add :owner_id, :string, null: false
    add :title, :string, null: false
    add :encrypted_body, :text, null: false

    timestamps(type: :utc_datetime)
  end
end

The plaintext body field is virtual. The changeset encrypts it into encrypted_body; body is never persisted in the Phoenix application table.

5. Understand alias and AAD policy

For this small example, KMS policy lives directly in the Document module.

Each owner gets one KEK alias, e.g. owner:alice. Many documents for the same owner share that KEK.

AAD stays field-specific: document:#{document_id}/body. It binds encryption/decryption to the body field of that document row. Copying encrypted_body to another document id does not decrypt.

See Additional authenticated data.

6. Encrypt transparently in the changeset

The changeset owns encryption. The Phoenix context stays boring:

def create_document(attrs) do
  Repo.transaction(fn ->
    case %Document{} |> Document.changeset(attrs) |> Repo.insert() do
      {:ok, document} -> document
      {:error, changeset} -> Repo.rollback(changeset)
    end
  end)
end

Reads decrypt before returning to caller:

def get_document!(id) do
  Document
  |> Repo.get!(id)
  |> with_body!()
end

Application code passes body to the changeset and gets body back from the context. The database stores only encrypted_body.

7. Prove plaintext stays out of the app table

The example test proves encryption, owner-scoped aliasing, and AAD binding:

test "encrypts document body through KMS and decrypts transparently" do
  assert {:ok, document} =
           Documents.create_document(%{
             owner_id: "alice",
             title: "Launch notes",
             body: "Keep this plaintext out of the application table"
           })

  stored = Repo.get!(Document, document.id)

  assert stored.body == nil
  refute stored.encrypted_body =~ "Keep this plaintext"
  assert Documents.get_document!(document.id).body == document.body

  assert %KMS.Kek{} = KMS.Keks.get_by_alias(Document.alias_name("alice"))
end

Run it:

cd example/phoenix_example
mix deps.get
mix ecto.reset
mix test

Current evidence from this worktree:

7 tests, 0 failures

8. Use KMS directly from IEx

After setup:

iex -S mix

Create and read an encrypted document:

{:ok, document} =
  PhoenixExample.Documents.create_document(%{
    owner_id: "alice",
    title: "Private note",
    body: "only plaintext in app memory"
  })

PhoenixExample.Documents.get_document!(document.id).body

Inspect stored ciphertext:

stored = PhoenixExample.Repo.get!(PhoenixExample.Documents.Document, document.id)
stored.encrypted_body
stored.body

stored.body is nil; stored.encrypted_body is ciphertext.

Next steps