mix phoenix_kit.integrations.rotate_key (phoenix_kit v2.13.7)

Copy Markdown View Source

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

  1. Reads every stored integration connection.
  2. Decrypts each one under whichever key is CURRENTLY active (a dedicated :integrations_encryption_key if configured, else the legacy secret_key_base-derived key — see PhoenixKit.Integrations.Encryption).
  3. 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.
  4. 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.Integrations work 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.