Rotates the encryption key protecting stored integration credentials AND
restricted PhoenixKit.Settings values (oauth_*_client_secret,
aws_* — see PhoenixKit.Settings.restricted_setting_keys/0). Both are
encrypted under the same resolved key, so both must rotate together.
Usage
$ mix phoenix_kit.integrations.rotate_key --dry-run
$ mix phoenix_kit.integrations.rotate_key
$ mix phoenix_kit.integrations.rotate_key --new-key="<already-generated-secret>"
What it does
- If a key store is configured (
:integrations_key_store), checks it can be written to — BEFORE touching any data. Rotation is the dangerous moment: once rows are re-encrypted, a store that then refuses the write leaves you holding a database no key opens. - Reads every stored integration connection and every restricted setting value.
- Decrypts each one under whichever key is CURRENTLY active (a
dedicated
:integrations_encryption_keyif configured, then a configured key store, else the legacysecret_key_base-derived key — seePhoenixKit.Integrations.Encryption). - If every row decrypts cleanly, re-encrypts all of them under the new secret in a single database transaction — either every connection rotates, or (on any failure) none do.
- Stores the new secret and reads it back to confirm it landed before
reporting success. A write that returns
:okand did not land is the failure this exists to prevent.
With and without a key store
With :integrations_key_store configured, the secret is written there and
the app picks it up on restart — no config edit, and the secret is not
printed because it does not need to be. See
PhoenixKit.Integrations.KeyStore; the default
PhoenixKit.Integrations.KeyStore.File writes one file, mode 0600, outside
the repository, and is per-host.
Without one, behaviour is unchanged: the secret is printed exactly once and
saved nowhere, with a warning saying so. You must then configure
integrations_encryption_key yourself and restart.
Migrating from an explicit key to a store is one rotation: run this with both
set (the explicit key is what decrypts the current rows), then remove
integrations_encryption_key and restart. The task says so explicitly when it
sees both, because an explicit key outranks the store and a restart before
removing it would read nothing.
When to run this
- First adoption — no dedicated key is configured yet, so every
connection is protected only by the legacy
secret_key_base-derived key. Run this once, set the printed secret, restart. - Suspected key compromise — a dedicated key is already configured. Run this, replace the env var with the newly printed secret, restart. Treat the old key as permanently compromised; do not reuse it.
Options
--dry-run— runs the decrypt-and-verify pass over every row and reports how many WOULD rotate, without generating a key or writing anything. Unlike a real rotation, this does NOT take row locks — it's a plain read, safe to run against live traffic at any time, not just before committing to a real rotation.--new-key— supply your own secret instead of generating one (e.g. one already stored in a secrets manager). Skipped in--dry-run. Must not be empty —--new-key=""is refused outright rather than silently falling back to a generated secret, since that's very likely a shell variable that resolved empty (--new-key="$MAYBE_UNSET") and not something you meant to ask for.
Run this with nothing else writing to integration connections or restricted settings
A real rotation's row lock only defends against ONE direction of a race
with a concurrent writer (an OAuth token auto-refresh, a "Test
Connection" click, an admin saving the Authorization settings page...) —
see PhoenixKit.Integrations.KeyRotation's moduledoc, "Atomicity and
concurrent writers", for the exact mechanism and what it does NOT cover.
In short: a writer that already read a row before rotation locked it can
still silently overwrite the freshly rotated row with old-key content
after rotation commits, and rotate/2 will have already reported
success by then. Pause anything that could write to integration
connections or restricted settings (most concretely: an OAuth
token-refresh worker, or the Authorization settings page) before running
this for real, and do not resume it until you have restarted the app
under the new key — not just until this command returns. Treat the
whole span, start to restart, as one maintenance window.
The gap between rotating and restarting
Rotation only changes what's in the database; the running app keeps using the OLD key until you set the new one and restart. This is the SAME maintenance window the section above requires — it doesn't end when this command returns, it ends when the app is running under the new key. In that window:
- A READ of a rotated connection does not raise or crash — a field
that can't be decrypted under the still-active old key is logged
and silently dropped from whatever asked for it (see
PhoenixKit.Integrations.Encryption's decrypt-failure handling), same as any other decrypt failure. Not an exception to catch — the field is simply absent. - A WRITE that must first read-and-merge the existing row (most writes
in
PhoenixKit.Integrationswork this way, including fully automatic ones like a validation-status update after every token-refresh attempt) is NOT destructive just because it hits a row this task already rotated: it restores the untouched field's ciphertext before saving, as long as the write itself doesn't supply a fresh value for that exact field. The field stays exactly as rotation left it and decrypts fine again once the app restarts. - A WRITE that DOES supply a fresh value for that exact field is still at risk, whether it's this simple gap or the race the section above describes: whatever gets encrypted uses whichever key is ACTIVE, which is still the OLD one until the app restarts, so that value lands under the OLD key in a row this task already moved to the new secret. It then fails to decrypt once the app restarts onto the new key, indistinguishable from unrelated corruption.
There is no dual-key fallback to paper over any of this (it would silently mask exactly the failure class this task exists to prevent). Restart promptly, and keep writers paused until you do.