# `mix phoenix_kit.integrations.rotate_key`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.14.2/lib/mix/tasks/phoenix_kit.integrations.rotate_key.ex#L1)

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

1. 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.
2. Reads every stored integration connection and every restricted setting
   value.
3. Decrypts each one under whichever key is CURRENTLY active (a
   dedicated `:integrations_encryption_key` if configured, then a
   configured key store, else the legacy `secret_key_base`-derived key —
   see `PhoenixKit.Integrations.Encryption`).
4. 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.
5. Stores the new secret and **reads it back to confirm it landed** before
   reporting success. A write that returns `:ok` and 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.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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
