AES-256-GCM encryption for sensitive integration credentials.
Encrypts fields like access_token, refresh_token, client_secret,
api_key, bot_token, secret_key, password before storing in the
database. Decrypts them when reading.
Key resolution
The AES key is derived (SHA-256) from a secret, tried in this order:
Dedicated key —
config :phoenix_kit, :integrations_encryption_key. The recommended setup: a random secret independent of anything else in the app, generated withmix phoenix_kit.integrations.rotate_keyand wired from an environment variable inruntime.exs, e.g.config :phoenix_kit, integrations_encryption_key: System.get_env("PHOENIX_KIT_INTEGRATIONS_ENCRYPTION_KEY")Legacy fallback — the application's
secret_key_base(flatconfig :phoenix_kit, :secret_key_base, or the host app's own Endpoint secret). This is what every install used before the dedicated key existed, and it stays supported for backwards compatibility — but it means anyone who can readsecret_key_base(env, config file, git history) can decrypt every stored integration credential, since that secret is shared with session signing, CSRF tokens, and everything else Phoenix derives from it.status/0reports this tier as:legacy_secret_key_baseandPhoenixKit.Supervisorlogs a boot warning about it — seewarn_if_insecure/0.
Set config :phoenix_kit, integration_encryption_enabled: false to turn
encryption off entirely (new and existing writes store plaintext). This is
reported as :disabled_explicit by status/0 and is also warned about at
boot — the setting takes effect silently, but its EFFECT is never silent.
Key rotation
Changing which secret produces the key — setting a dedicated key for the
first time, or rotating an existing one — makes every existing enc:v1:
value undecryptable under the new key. There is no dual-key fallback at
read time (that would silently mask a misconfigured key with plaintext
read failures, the opposite of the point). Use
PhoenixKit.Integrations.KeyRotation.rotate/2 (or
mix phoenix_kit.integrations.rotate_key) to re-encrypt every stored
connection under the new secret BEFORE switching the app's config over
to it.
A field that's temporarily undecryptable (mismatched key, mid-rotation)
is never permanently lost by an unrelated write in the meantime — see
decrypt_fields_with_failures/1 and
PhoenixKit.Integrations.save_setup/4's write path. The stored
ciphertext survives untouched until the correct key is active again.
Summary
Types
The active tier plus why it is that tier.
Everything a surface needs to say about the key state, decided together.
The raw signals the key state is made of, gathered in one pass.
Which secret currently backs the encryption key, from most to least secure
Functions
Decrypt sensitive fields in an integration data map after reading.
Same as decrypt_fields/1, but also returns the names of any fields that
looked encrypted (carried the enc:v1: prefix) yet failed to decrypt
under the currently active key.
Decrypt a value produced by encrypt_value/1.
Check if encryption is available and enabled.
Encrypt sensitive fields in an integration data map before saving.
Encrypts sensitive fields using an EXPLICIT secret, bypassing the configured-key resolution entirely — the rotation primitive.
Encrypt a single value, for callers with a bare field to protect rather
than a full encrypt_fields/1-shaped map (e.g. an Ecto schema field like
PhoenixKit.Modules.Storage.Bucket.secret_access_key).
Encrypts a single value using an EXPLICIT secret — the single-value
counterpart to encrypt_fields_with_secret/2, for the same rotation
use case (PhoenixKit.Integrations.KeyRotation re-encrypting
PhoenixKit.Settings' restricted setting values, which are stored as a
bare value string rather than a value_json map of sensitive fields).
Whether value looks like an already-encrypted field value (carries the
current enc:v1: prefix).
The active tier and the reason for it. See key_diagnosis/0.
A short, non-reversible fingerprint of the key currently in use, or :none.
The complete report for the current state.
The verdict for a set of signals — total over the signal space.
The report rendered as one sentence, for a log line.
Reads every signal the key verdict depends on, in one pass.
Shortest secret accepted as a dedicated key.
Returns the list of field keys that are encrypted.
Reports which secret currently backs the encryption key. See key_status/0.
Logs a one-time warning when integration credentials are not protected by
a dedicated key — called once at boot by PhoenixKit.boot/1.
Deliberately silent (no log line) for the healthy :dedicated case; the
common, correctly-configured install must produce zero noise here.
Types
@type key_diagnosis() ::
{:dedicated, :ok | :store_unreadable | :store_shadowed}
| {:legacy_secret_key_base,
:store_unreadable | :store_shadowed | :key_too_short | :no_dedicated_key}
| {:disabled_no_key,
:store_unreadable | :store_shadowed | :key_too_short | :no_key_material}
| {:disabled_explicit, :turned_off}
The active tier plus why it is that tier.
Exists because two places give advice about the same situation —
warn_if_insecure/0 and mix phoenix_kit.doctor — and advice that is correct
for one reason is actively harmful for another. Telling an operator whose key
store is unreadable to run mix phoenix_kit.integrations.rotate_key would
rotate away from a key their data may still be encrypted under. Telling
someone who did configure a key that none is configured is simply false.
Both callers branch on this one value, so they cannot drift apart.
@type key_report() :: %{ diagnosis: key_diagnosis(), severity: :ok | :warn | :fail, summary: String.t(), consequence: String.t(), action: String.t(), rotation_safe?: boolean(), rejected_key: false | :config | :store, fingerprint: :none | {:ok, String.t(), String.t()}, key_store: nil | {:no_secret_yet | :unreadable | :shadowed | :holding, String.t()} }
Everything a surface needs to say about the key state, decided together.
One clause of key_report/1 produces a whole one of these. There is no path
by which its parts can disagree: the fingerprint and the tier that produced it
are a single term, and :key_store is nil unless a store is actually
configured.
@type key_signals() :: %{ enabled?: boolean(), tier: :dedicated | :legacy | :none, rejected_key: false | :config | :store, store: :absent | {:no_secret_yet, String.t()} | {:unreadable, String.t()} | {:shadowed, String.t()} | {:holding, String.t()}, fingerprint: :none | {:ok, String.t()} }
The raw signals the key state is made of, gathered in one pass.
Deliberately separate from the verdict below. Everything that decides what an operator should be told is read here, once, and then the verdict is a function of this map alone — it consults nothing further. Three rounds of fixes each produced a message contradicting itself because a later step went back to the environment for one more fact and got a different answer than the step before.
@type key_status() ::
:dedicated | :legacy_secret_key_base | :disabled_no_key | :disabled_explicit
Which secret currently backs the encryption key, from most to least secure:
:dedicated— a dedicated:integrations_encryption_keyis set.:legacy_secret_key_base— no dedicated key; falling back to a key derived fromsecret_key_base. Functional, but shares its secret with the rest of the app.:disabled_no_key— encryption is enabled but no key material at all resolves (neither a dedicated key nor a usablesecret_key_base). New writes store plaintext.:disabled_explicit—integration_encryption_enabled: false. New writes store plaintext.
Functions
Decrypt sensitive fields in an integration data map after reading.
Only values with the enc:v1: prefix are decrypted.
Non-encrypted values are returned as-is for backwards compatibility.
Same as decrypt_fields/1, but also returns the names of any fields that
looked encrypted (carried the enc:v1: prefix) yet failed to decrypt
under the currently active key.
decrypt_fields/1 drops an undecryptable field entirely — correct for
every caller that treats the result as a live credential to use or
display, since a caller must never mistake stale ciphertext for a real
value. But PhoenixKit.Integrations.resolve_uuid/2 also hands this same
map to write paths that merge new attributes onto it and save the result
wholesale: for THOSE callers, "absent because it failed to decrypt" and
"absent because nothing was ever there" are not the same thing — the
first must not be permanently erased by an unrelated write. The returned
field-name list lets a write path restore an untouched field's original
ciphertext from storage (see PhoenixKit.Integrations.save_setup/4,
refresh_access_token/1, exchange_code/4, record_validation/3)
without ever exposing that ciphertext as if it were usable.
Decrypt a value produced by encrypt_value/1.
Returns {:ok, plaintext}. A value without the enc:v1: prefix is
returned as {:ok, value} unchanged — backwards compatibility with
data written before encryption was applied. {:error, :encryption_unavailable}
when the value IS prefixed but no encryption key is available (no
secret_key_base) — unlike the nil-key path in encrypt_value/1, there
is no plaintext to fall back to here, only ciphertext nobody can read
right now. {:error, reason} for any other decrypt failure (wrong/rotated
key, corrupted ciphertext).
@spec enabled?() :: boolean()
Check if encryption is available and enabled.
True for both the :dedicated and :legacy_secret_key_base tiers — this
answers "will values be encrypted at all", not "how well". Use status/0
to distinguish the two.
Encrypt sensitive fields in an integration data map before saving.
Non-sensitive fields and nil/empty values are left unchanged.
Already-encrypted values (with enc:v1: prefix) are not re-encrypted.
Encrypts sensitive fields using an EXPLICIT secret, bypassing the configured-key resolution entirely — the rotation primitive.
secret is derived the same way a configured key would be
(derive_key/1); this does not read :integrations_encryption_key or
secret_key_base. Used by PhoenixKit.Integrations.KeyRotation to write
values under a NEW secret before that secret becomes the active
configured key — which is the whole point of rotation: the new key must
be usable to encrypt before it's the one encryption_key/0 resolves to.
Encrypt a single value, for callers with a bare field to protect rather
than a full encrypt_fields/1-shaped map (e.g. an Ecto schema field like
PhoenixKit.Modules.Storage.Bucket.secret_access_key).
Same cipher, key derivation and enc:v1: prefix as encrypt_fields/1.
Nil/empty and already-encrypted values (see encrypted?/1) pass through
unchanged. When encryption is unavailable (no secret_key_base), the
value is stored as plaintext — same as encrypt_fields/1 — but this
path logs a warning, since a caller that reaches for single-value
encryption is usually protecting something as sensitive as the fields
encrypt_fields/1 already covers, and a schema field silently staying
plaintext is exactly the gap this API exists to close.
Encrypts a single value using an EXPLICIT secret — the single-value
counterpart to encrypt_fields_with_secret/2, for the same rotation
use case (PhoenixKit.Integrations.KeyRotation re-encrypting
PhoenixKit.Settings' restricted setting values, which are stored as a
bare value string rather than a value_json map of sensitive fields).
Nil/empty pass through unchanged, same as encrypt_value/1. Unlike
encrypt_value/1, does NOT skip already-encrypted input — rotation
always calls this with a value it just decrypted under the OLD key, so
by the time it reaches here it is plaintext, never still enc:v1:-prefixed.
Whether value looks like an already-encrypted field value (carries the
current enc:v1: prefix).
Used to keep encrypt_value/1 idempotent (never double-encrypt) and to
let callers tell an already-migrated field apart from legacy plaintext.
Also public so callers outside this module —
PhoenixKit.Integrations.KeyRotation detecting which fields were
encrypted before a rotation — don't hardcode the prefix literal
themselves. A future enc:v2: format only needs to update this one
place.
@spec key_diagnosis() :: key_diagnosis()
The active tier and the reason for it. See key_diagnosis/0.
The reasons are ordered by what must be acted on first: an unreadable key store outranks "no dedicated key", because repairing the store may restore the very key the data is encrypted under, while rotating would abandon it.
@spec key_fingerprint() :: {:ok, String.t()} | :none
A short, non-reversible fingerprint of the key currently in use, or :none.
Exists so that key reuse between sites is visible. derive_key/1 is a
plain SHA-256 over the secret, so two installs that share a secret_key_base
— copied from a template, cloned from a sibling environment, inherited with a
config/dev.exs — derive a byte-identical integration key and neither has any
way to notice. One compromise then exposes every site that shares it.
Two installs showing the same fingerprint are using the same key.
Comparing two numbers only means something like-for-like
Three things make one site show a fingerprint that is not "its" key, and none of them are visible in the number itself, so always read the tier printed next to it:
- an unreadable key store, so the fallback key is being fingerprinted;
- a dedicated key rejected as too short, same effect;
- encryption disabled, in which case there is no fingerprint at all.
There is also an environment trap. mix phoenix_kit.doctor resolves the key
in the task's environment; a key delivered by an env var read in
runtime.exs may differ from what the running server holds. A task and an
admin page on the same site can therefore disagree. Compare admin page with
admin page, or task with task.
What it gives away
Domain-separated from derive_key/1 and truncated, so it is not the key and
cannot be turned back into one. It IS a verifier: anyone holding it can test
candidate secrets offline.
There is no salt, and there cannot be one — a per-install salt would make two
installs with the same key show different numbers, destroying the only thing
this is for. What can be raised is the cost per guess, so the digest is
iterated (PBKDF2-HMAC-SHA256, 100000 iterations) instead
of the two plain hashes it used to be. The prefix is global to PhoenixKit, so
a table over common secret_key_base values works against every install at
once; iteration makes building that table that many times more expensive, and
nothing more. The fingerprint is no stronger than the secret behind it —
against a weak, guessable secret it is a verification oracle, which is exactly
the situation this feature exists to surface.
Accordingly it is shown on the admin-only system page, and by
mix phoenix_kit.doctor only when explicitly asked for: a task's output ends
up in CI logs, whose readership is wider than the page's.
@spec key_report() :: key_report()
The complete report for the current state.
@spec key_report(key_signals()) :: key_report()
The verdict for a set of signals — total over the signal space.
Public because the acceptance criterion here is an enumeration: every combination rendered whole and read for self-contradiction. That needs a seam that takes the state rather than discovering it.
Ordered by the tier first, and deliberately
Three rounds ordered the clauses by fault — unreadable store, then short key, then tier — and each round found a state where the fault clause fired over a tier that was working, and announced a fallback that was not happening. The fix each time was to move one clause; the next round found the next one.
The tier is the ground truth: it is which key is protecting the data right now. A fault is a modifier on that, never a replacement for it. So the tier is matched first and the fault second, which has two consequences worth stating:
- no combination of signals can produce a report claiming a fallback while the signals say a dedicated key is in use — not because that combination is argued to be unreachable, but because no clause can say it;
- the verdict is total. Every point in the signal space renders, and the test walks all of them rather than filtering by a reachability rule. Reachability rules were themselves the defect twice: they were reasoned out by whoever reasoned out the code, and they excluded states the code could actually reach.
Within a tier, the faults are ordered by which one an operator must clear
first. For the weaker tiers a rejected key outranks an unreadable store,
because dedicated_candidate/2 never consults the store while config answers
— so repairing the store cannot change anything until the short key is gone.
@spec key_report_message(key_report()) :: String.t()
The report rendered as one sentence, for a log line.
Never includes the fingerprint: a log's readership is wider than the admin page's, and the fingerprint is a verifier against candidate secrets.
@spec key_signals() :: key_signals()
Reads every signal the key verdict depends on, in one pass.
@spec min_dedicated_key_length() :: pos_integer()
Shortest secret accepted as a dedicated key.
Public so mix phoenix_kit.integrations.rotate_key can refuse a --new-key
this module would later reject: without the check the rotation "succeeds",
the data is re-encrypted, and the app silently drops to a weaker tier on the
next restart.
@spec sensitive_fields() :: [String.t()]
Returns the list of field keys that are encrypted.
@spec status() :: key_status()
Reports which secret currently backs the encryption key. See key_status/0.
Never touches the database, and safe to call from a boot hook or a LiveView
mount/3. It is no longer pure config introspection: when a key store is
configured it may read from it (memoised after the first success). A
host-supplied store cannot crash this call — PhoenixKit.Integrations.KeyStore
turns a raising store into an error tuple.
@spec warn_if_insecure() :: :ok
Logs a one-time warning when integration credentials are not protected by
a dedicated key — called once at boot by PhoenixKit.boot/1.
Deliberately silent (no log line) for the healthy :dedicated case; the
common, correctly-configured install must produce zero noise here.
An :integrations_encryption_key shorter than the minimum length gets
its OWN message rather than being folded into the "no dedicated key"
wording below — an operator who set one, just too short, needs
different advice than one who never set it, and telling them "no
dedicated key is configured" when they configured one is simply false.
Never raises — returns :ok unconditionally.