---
title: "Key rotation"
manual: "nr-vault"
version: "1.0"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-vault:operations-key-rotation@1.0"
source: "Operations/KeyRotation.rst"
rendered: "2026-09-18T07:37:50+00:00"
---

# Key rotation {#key-rotation}

Rotating the master key re-wraps every DEK under a new key. Secret *values*
are never re-encrypted and never materialise as plaintext — only the DEK layer
changes ([Envelope scheme](https://docs.typo3.org/permalink/netresearch/nr-vault:security-cryptography-envelope@1.0)), which is what makes the
operation affordable at all.

It is also the most powerful operation in the extension: it rewrites every
envelope and rekeys the audit chain in one transaction. It carries its own
permission, `master_key.rotate`.

> [!WARNING]
> **The command does not install the new key for you.** It re-wraps
> everything under the key you hand it and then tells you to update the
> configuration. Between the commit and that configuration change, secrets
> cannot be decrypted. Plan for that window — see
> [The configuration switch — and the window](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-key-rotation-window@1.0).

## When to rotate {#when-to-rotate}

-   After any suspected exposure of the key material, or of a host that could
    read it.
-   After personnel with filesystem or KMS access leave.
-   On a schedule — annually is a common policy.
-   When moving between providers, for example off `typo3` as part of
    [Migrating from standard to hardened](https://docs.typo3.org/permalink/netresearch/nr-vault:security-profiles-migration@1.0).

Rotating the *master key* is not the same as rotating a *secret*. If a single
credential leaked, rotate that credential and use `vault:rotate`; a master
key rotation changes nothing about a leaked credential value.

## The command {#the-command}

```bash
vendor/bin/typo3 vault:rotate-master-key \
    --old-key=/path/to/old.key \
    --new-key=/path/to/new.key \
    [--dry-run] [--confirm]
```

| Option | Meaning |
| --- | --- |
| `--old-key` | Path to a **file** containing the old master key. Omitted: the currently configured provider's key is used. |
| `--new-key` | Path to a **file** containing the new master key. Omitted: the currently configured provider's key is used — which, with `--old-key` also omitted, means both are identical and the command refuses. |
| `--dry-run` | Inventory, confirmation and the old-key smoke test run; the re-encryption does not. No changes are made. |
| `--confirm` | Required for an actual run. Without it (and without `--dry-run`) the command aborts rather than asking. |

Both keys are read from files, not from the command line, so they do not land
in shell history or a process listing. Both copies are wiped with
`sodium_memzero()` when the command returns.

## Preflight {#preflight}

The command performs these checks itself, in this order, and each one aborts:

1.  **Permission.** `master_key.rotate` must be granted. On CLI without a
    backend user that means `allowCliAccess = 1` **and** `master_key.rotate`
    listed in [cliAllowedOperations](https://docs.typo3.org/permalink/netresearch/nr-vault:confval-ext-nrvault-cliallowedoperations@1.0), which excludes it by
    default; as a backend user it means the admin override or an explicit group
    grant.
1.  **Keys must differ.** Compared with `hash_equals()`. Identical keys
    are refused as *"Nothing to rotate."*
1.  **Inventory.** Vault secrets are counted, and so are consumer-owned
    foreign envelopes registered by other extensions
    ([ADR-033: Master-key rotation reaches consumer-owned envelopes](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-033-foreign-envelope-rotation@1.0)) — a vault with no secrets of
    its own may still be the key authority for thousands of them.
    The audit history is counted as well: rows sealed under a
    master-key-derived HMAC key (epoch 1 and up) and the chain-tip anchor.
    A vault whose secrets have all been deleted still holds that history,
    and it has to move to the new key like everything else.
    Only when secrets, foreign envelopes, keyed audit rows and anchor are all
    absent does the command stop with a warning.
1.  **Confirmation.** `--confirm` for a real run.
1.  **Old-key smoke test.** One real secret is re-encrypted to prove the
    supplied old key is actually the one that wrapped the envelopes. This is
    the check that turns "wrong key" from a half-rotated vault into a clean
    abort.

    > [!NOTE]
    > With no vault secrets of its own the smoke test cannot run, and the
    > command says so. A wrong key then surfaces as a failure of the
    > consumer-envelope pass, which rolls the rotation back — a lost early
    > warning, not a lost safety net.

Your own preflight, before running any of it:

-   -   **\[ \] **A verified backup of the database and of the current key material.****

        If the rotation fails in a way the transaction cannot undo, this is the
        only way back. See [Backup and restore](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-backup-and-restore@1.0).
-   -   **\[ \] \*\*The new key exists, is 32 bytes, and is stored where the provider**

        will read it after the switch.\*\*
-   -   **\[ \] **The audit chain verifies now.** Rotating over an already-broken**

        chain destroys your ability to tell the two problems apart:

        ```bash
        # Asserts audit.view; on the CLI that needs allowCliAccess = 1 AND
        # audit.view in cliAllowedOperations, or a named technical actor.
        vendor/bin/typo3 vault:audit-verify
        ```
-   -   **\[ \] **A maintenance window.** Vault reads fail between the commit and the**

        configuration switch.
-   -   **\[ \] **\`\`vault:doctor\`\` is clean**, so a pre-existing misconfiguration does**

        not surface mid-rotation.

## Dry run {#dry-run}

Always run this first:

```bash
vendor/bin/typo3 vault:rotate-master-key \
    --old-key=/path/to/old.key --new-key=/path/to/new.key --dry-run
```

The dry run is genuinely useful rather than cosmetic, because the old-key
smoke test happens **before** the short-circuit: a dry run that succeeds has
proved that the old key opens a real envelope. It reports the number of
secrets and foreign envelopes that would be re-wrapped and the number of audit
rows that would be re-keyed, and makes no changes.

A dry run that fails the smoke test means the old key is wrong. Fix that
before going further; do not proceed with `--confirm` hoping the real run
behaves differently.

## What the real run does {#what-the-real-run-does}

Everything below happens inside **one database transaction**, and any failure
rolls back secrets, audit events and the chain rewrite together.

1.  A `master_key_rotate_start` audit row is written under the pseudo
    identifier `__master_key__`.
1.  Every secret's DEK is unwrapped with the old key and re-wrapped with the
    new one, with a fresh nonce. Values, value nonces and the
    version/algorithm markers are untouched.
1.  Registered foreign envelopes are re-wrapped the same way.
1.  The audit advisory lock is taken for the remainder of the transaction, so
    no concurrent writer can chain onto a tip hash that is about to be
    rewritten.
1.  The `audit_chain_rekey` and successful `master_key_rotate_end` rows
    are appended — still sealed with the old, provider-derived HMAC key.
1.  **The whole audit chain is rewritten** under the HMAC key derived from the
    new master key, including the two rows just appended, so the committed
    chain verifies under the new key from first row to last with no old-keyed
    tail.
1.  Commit, then `MasterKeyRotatedEvent` is dispatched.

Two properties of the rekey worth knowing:

-   **Per-row epochs are preserved.** Re-keying changes the key, never the
    payload format. An epoch-2 row stays epoch 2.
-   **An all-epoch-0 chain passes through untouched.** Keyless SHA-256 hashes
    do not depend on the master key, so rows are only updated when their
    hashes actually change.

## The configuration switch — and the window {#the-configuration-switch-and-the-window}

On success the command prints its next steps, and they are not advisory:

1.  **Update the configuration to use the new master key immediately.** Until
    then, secrets cannot be decrypted *and* audit-chain verification runs
    against the old key. Depending on the provider: replace the key file
    (`file`), re-inject the environment variable and restart (`env`), or
    re-wrap the new key with the KMS (`transit`).
1.  **Securely archive or destroy the old key.** Archive it if you may still
    need to open an older backup; destroy it if you may not. Do not leave it
    where the vault could pick it up again.
1.  **Test retrieval and verify the chain** — see below.
1.  **Re-seal any rows written in the gap.** Audit rows written between the
    rotation commit and the configuration switch are sealed with the old
    HMAC key while the rest of the chain is under the new one. Re-seal them
    with `vault:audit-migrate-hmac`.

That fourth step is the reason to keep the window short and to make it a
maintenance window: every audited operation during the gap adds a row that
will need re-sealing.

## Verification {#verification}

```bash
# 1. The provider resolves and the configuration is coherent.
vendor/bin/typo3 vault:doctor --format=json

# 2. Plaintext comes back — the real proof. On the CLI this needs
#    allowCliAccess = 1 AND secret.reveal in cliAllowedOperations;
#    a reveal in the backend module proves the same decrypt path.
vendor/bin/typo3 vault:retrieve <a-known-identifier>

# 3. The chain verifies under the new key, end to end. Needs audit.view:
#    on the CLI that is allowCliAccess = 1 AND audit.view in
#    cliAllowedOperations, or a named technical actor.
vendor/bin/typo3 vault:audit-verify

# 4. Anchor the new tip; the old anchor's baseline is now stale. Needs
#    vault.configure, on the same terms as step 5 below.
vendor/bin/typo3 vault:audit-anchor

# 5. The in-database anchor is re-sealed by the rotation itself. Confirm
#    it reads "Tip anchor: ok" — a re-key that left it UNREADABLE means
#    the re-seal did not complete, and that is a finding, not a nuisance.
#    Needs vault.configure: on the CLI that is allowCliAccess = 1 AND
#    vault.configure in cliAllowedOperations, or a named technical actor.
vendor/bin/typo3 vault:audit --verify
```

Probe at least two secrets, and pick them from different encryption versions
if the installation has both — see
[Restore verification: the probe decrypt](https://docs.typo3.org/permalink/netresearch/nr-vault:operations-backup-and-restore-verification@1.0) for why.

> [!NOTE]
> Re-anchoring after a rotation is not optional. The rewritten chain has new
> hashes throughout, so an anchor taken before the rotation will now report
> `TABLE_RESET` against it. Publish a fresh anchor, and keep the old one
> with your rotation record so the finding is explainable rather than
> alarming.

## If it fails {#if-it-fails}

-   **Aborted during preflight** — nothing changed. Fix and retry.
-   **Rolled back** — the transaction reverted secrets, the appended events
    and the chain rewrite together. A `master_key_rotate_end` row with
    `success = 0` and the reason *"Unexpected error during rotation;
    transaction rolled back"* records the attempt. The old key is still the
    right one; do **not** switch the configuration.
-   **Committed but the configuration was never switched** — secrets stay
    undecryptable and verification uses the wrong key. Complete the switch;
    this is a half-finished rotation, not a failed one.
-   **Committed, configuration switched, and reads still fail** — the key you
    installed is not the `--new-key` you rotated to. Install the correct
    one. Do not rotate again to "fix" it: a second rotation from the wrong old
    key will fail its smoke test, which is the safety net working.

> [!WARNING]
> Never run `vault:init` to recover from a failed rotation. It generates a
> fresh master key, and a fresh key over a rotated vault makes every secret
> unreadable — the command's own `--force` warning says as much.

## Transit key rotation is a different operation {#transit-key-rotation-is-a-different-operation}

With the `transit` provider there are **two independent rotations**, and
confusing them is a common mistake.

| Operation | Effect |
| --- | --- |
| `vault:rotate-master-key` | Changes the nr-vault **master key**: every DEK is re-wrapped and the audit chain is rekeyed. Installing the new key afterwards means wrapping it with Vault and replacing the local blob — the command does not do that for you. This is what the rest of this page describes. |
| `vault write -f transit/keys/<name>/rotate` | Rotates the **Vault transit key**. New ciphertexts are wrapped under the new transit key version; existing ciphertexts stay decryptable under their recorded version. **The nr-vault master key does not change**, so no DEK is re-wrapped, the audit chain is untouched, and no secret is affected. |

So: rotating the transit key is a KMS hygiene operation with no vault
downtime and no re-encryption. Rotating the master key is the operation that
touches every envelope. Do the first freely; schedule the second.

To re-wrap the existing master key under the newest transit key version
without changing the master key itself, use Vault's `rewrap` endpoint on the
stored ciphertext and replace the local blob — again with no effect on any
secret. Keep the file mode at `0600` and write it atomically, as the
provider itself does.
