Rotates the encryption key protecting stored integration credentials.
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
- Reads every stored integration connection.
- Decrypts each one under whichever key is CURRENTLY active (a
dedicated
:integrations_encryption_keyif configured, 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.
- Prints the new secret (unless you supplied one with
--new-key) and the config to set.
This task does NOT write any config file or environment variable — you
must configure integrations_encryption_key yourself (typically from an
env var in runtime.exs) and restart the app.
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
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, ...) — 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 (most concretely: an OAuth token-refresh
worker) 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.