Where the integrations encryption secret is kept, as an extension point.
mix phoenix_kit.integrations.rotate_key used to print a freshly generated
secret exactly once and save it nowhere. An operator who lost that line lost
every stored integration credential with it — the ciphertext stays in the
database and nothing can read it again. This behaviour exists so the secret
has somewhere to land.
The default is deliberately boring
PhoenixKit.Integrations.KeyStore.File writes one file, mode 0600, outside
the repository. No external service, no account, no credentials of its own —
it works for any PhoenixKit user on any host, which a cloud secrets manager
does not. Hosts that want Vault, AWS Secrets Manager or anything else
implement this behaviour and configure their module instead; nothing here
assumes the default.
Configuring
config :phoenix_kit, integrations_key_store: PhoenixKit.Integrations.KeyStore.File
# or, with options
config :phoenix_kit,
integrations_key_store: {PhoenixKit.Integrations.KeyStore.File, path: "/etc/phoenix_kit/app.key"}Unconfigured is a supported state, not a broken one: rotation keeps its old print-once behaviour and says plainly that the secret was saved nowhere.
Reading
PhoenixKit.Integrations.Encryption consults the store when no explicit
:integrations_encryption_key is configured, so a rotation followed by a
restart needs no config edit. The read is memoised in :persistent_term —
encryption runs per credential and must not hit the filesystem each time.
Rotation invalidates that cache, and so can invalidate_cache/0.
Secrets never travel in error terms
Every callback returns the path or a reason, never the secret. A secret in an error tuple ends up in a log, a crash report or a support ticket, and a secret that reaches a log is compromised in the only sense that matters.
Summary
Types
A store implementation plus the options it was configured with.
Callbacks
Human-readable location, for operator-facing messages. Never the secret.
Checks the store can be written to, without writing the real secret.
Reads the stored secret.
Persists secret, replacing whatever was there.
Functions
Memoised read/0, for the encryption hot path.
The configured store as {module, opts}, or nil.
Whether a store is configured at all.
Where the configured store keeps the secret, for messages. Never the secret.
A short description of a store error, safe to put in a log or a message.
Drops the memoised secret. Safe to call when nothing is cached.
Calls a store callback without letting a secret escape into an exception.
Runs the configured store's pre-flight check.
Reads the secret from the configured store.
Writes the secret, then reads it back and compares before reporting success.
Types
Callbacks
Human-readable location, for operator-facing messages. Never the secret.
Checks the store can be written to, without writing the real secret.
Called before a rotation re-encrypts anything. Rotation is the dangerous moment: once rows are re-encrypted, a store that then refuses the write leaves the operator holding a database no key opens. Finding out first turns that into an abort that changed nothing.
Reads the stored secret.
:not_configured means the store has nothing yet (a first run), which is
different from {:error, reason} — an existing secret that could not be
read. Callers must not collapse the two: "no key yet" invites writing one,
"could not read" must never lead to overwriting one.
Persists secret, replacing whatever was there.
Implementations must not log the secret, and must not leave a partially written file behind on failure.
Functions
Memoised read/0, for the encryption hot path.
Encryption runs once per credential field; an unmemoised read would stat and
open a file on every one. The cache is invalidated by invalidate_cache/0,
which rotation calls after storing a new secret.
@spec configured() :: configured() | nil
The configured store as {module, opts}, or nil.
Accepts a bare module or a {module, opts} tuple.
@spec configured?() :: boolean()
Whether a store is configured at all.
@spec describe() :: String.t() | nil
Where the configured store keeps the secret, for messages. Never the secret.
A short description of a store error, safe to put in a log or a message.
Deliberately NOT inspect/1 of the whole term. The reasons produced here are
known to be safe, but a host-supplied store returns whatever it likes from
read/1 and write/2 — including, plausibly, an error that quotes the value
it failed to store. Only the recognised shapes are formatted; anything else is
reduced to its outermost tag, so an unknown term cannot carry a secret into a
log by accident.
@spec invalidate_cache() :: :ok
Drops the memoised secret. Safe to call when nothing is cached.
Calls a store callback without letting a secret escape into an exception.
Public because PhoenixKit.Integrations.KeyStore.Chain calls member stores
and must not bypass this: apply/3 on a mistyped or unloaded module raises,
and Erlang formats such reports with their arguments — which for write/2 is
the secret.
@spec preflight() :: :ok | :not_configured | {:error, term()}
Runs the configured store's pre-flight check.
:not_configured when there is no store — not an error; the caller decides
whether that is acceptable.
Reads the secret from the configured store.
Returns :not_configured both when no store is configured and when the
configured store holds nothing yet — from a caller's point of view those are
the same situation: there is no secret to use.
Writes the secret, then reads it back and compares before reporting success.
A write that reports :ok and did not land is the failure this whole module
exists to prevent, and it is not hypothetical: a key file was lost this way
on 2026-08-18 — the file looked intact and the value inside was empty. So the
write is not trusted on its own word; it is verified by reading.
{:error, {:verification_failed, _}} means the data may be re-encrypted
while the secret is NOT safely stored. Callers must treat that as loud.