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
]
endThe 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]
]
endThe 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.Repofor 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
endMigration:
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
endThe 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)
endReads decrypt before returning to caller:
def get_document!(id) do
Document
|> Repo.get!(id)
|> with_body!()
endApplication 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"))
endRun it:
cd example/phoenix_example
mix deps.get
mix ecto.reset
mix test
Current evidence from this worktree:
7 tests, 0 failures8. 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).bodyInspect stored ciphertext:
stored = PhoenixExample.Repo.get!(PhoenixExample.Documents.Document, document.id)
stored.encrypted_body
stored.bodystored.body is nil; stored.encrypted_body is ciphertext.
Next steps
- Use AAD consistently for encrypted fields.
- Choose and back up an RMK provider.
- Read Database configuration for SQLite/PostgreSQL options.
- Read Security before production.
- Explore use cases, including standalone KMS.