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
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).
Whether value looks like an already-encrypted field value (carries the
current enc:v1: prefix).
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_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.
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 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 raises, never touches the database — pure config/endpoint
introspection, safe to call from a boot hook or a LiveView mount/3.
@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.