# Rotate a virtual key with zero downtime

Roll a virtual key to a successor without dropping traffic — both resolve through a grace window, and the ledger records the lineage.

Source: https://tokentriage.com/docs/recipes/rotate-a-virtual-key

---
import Callout from '../../../components/Callout.astro';
import NextSteps from '../../../components/NextSteps.astro';

Roll a compromised or aging virtual key to a fresh successor **without dropping a single request**: `tokentriage keys rotate` mints a successor and keeps the predecessor valid through a grace window, so callers migrate on their own schedule and the ledger records exactly which key served each row.

## Prerequisites

- A running TokenTriage deployment with governance enabled and at least one active `ttv_` key. See [Virtual keys](/docs/build/governance-keys).
- Shell access to a host that can reach `governance.db` (by default under the `--state-dir`, which also holds `audit.jsonl`).
- The **kid** of the key you are rotating. It is the `vk_`-prefixed public id, not the secret. List it if you don't have it:

```console
$ tokentriage keys list
KID             STATE    OWNER                    STUB     EXPIRES              LABEL
vk_FEKZdMs2UQRi active   org:acme/team:ml/user:dev1 EeNp     2026-11-07T20:41:14Z dev1-laptop
```

## Steps

### 1. Rotate with a grace window

Pass the predecessor's kid and a `--grace` duration. `--grace` is a Go duration string (`24h`, `30m`, `168h`) — during that window **both** the predecessor and the successor resolve, so in-flight callers keep working while you distribute the new secret.

```console
$ tokentriage keys rotate vk_FEKZdMs2UQRi --grace 24h
rotated vk_FEKZdMs2UQRi -> vk_xXxNzkjKvCAX (predecessor valid for 24h0m0s)
kid:     vk_xXxNzkjKvCAX
This secret is shown ONCE and cannot be recovered — copy it now:
ttv_FodkC2K2Vozp…
```

**You should see** a `rotated … -> …` line naming the predecessor and the new successor kid, followed by the shown-once secret block. The successor **inherits the predecessor's owner, scopes, label, dims policy, and metadata**, so you do not re-specify them.

Note that the command does not print an `expires:` line even when the successor inherits a real
expiry from its predecessor. `keys list` in the next step shows the truth.

<Callout type="warning">
The successor secret is printed to **this terminal only, once**. What `governance.db` keeps is a SHA-256 hash of it plus a four-character stub for matching, so the secret cannot be re-derived. Copy it now; if you lose it, rotate again.
</Callout>

### 2. Distribute the new secret before the window closes

Update your callers to present the new `ttv_…` secret. The predecessor keeps resolving until `now + grace`, and no longer than its own pre-existing expiry, so a rotation cannot extend a key's life. Past that deadline the gate stops accepting it.

Confirm both states while the window is open:

```console
$ tokentriage keys list
KID             STATE    OWNER                    STUB     EXPIRES              LABEL
vk_FEKZdMs2UQRi active   org:acme/team:ml/user:dev1 EeNp     2026-08-10T20:47:20Z dev1-laptop
vk_xXxNzkjKvCAX active   org:acme/team:ml/user:dev1 Fodk     2026-11-07T20:41:14Z dev1-laptop
```

Read that table across: both rows say `active`, the predecessor's `EXPIRES` was rewritten to the grace deadline, and the successor kept the original expiry. That is the window.

<Callout type="note">
A lapsed key keeps reporting `active` in this listing. Expiry is applied at the gate on every request, so the key stops working on time, but the sweep that would restamp its state as `expired` has no caller in this build. Trust the `EXPIRES` column over the `STATE` column.
</Callout>

### 3. (Optional) Revoke immediately for a compromised key

If the key is compromised and you cannot tolerate any overlap, pass `--grace 0` (the default). The successor is minted and the predecessor is **revoked immediately** — the next snapshot swap misses it.

```console
$ tokentriage keys rotate vk_TJjLgu3CXQw6 --grace 0
rotated vk_TJjLgu3CXQw6 -> vk_oXrW71or9o3n (predecessor revoked immediately)
kid:     vk_oXrW71or9o3n
This secret is shown ONCE and cannot be recovered — copy it now:
ttv_X_r7trsW-iiV…
```

<Callout type="warning">
`--grace 0` drops callers still on the old secret. Use it for a compromise; use a non-zero window for a routine roll. Rotation also **requires the predecessor to be active**, so a revoked or expired key cannot be rotated. And the management API's rotate endpoint always behaves like `--grace 0`: a window is available only from the CLI.
</Callout>

## What the ledger records

Both secrets work during the window, and the rows say which one served each request. A request presenting the **new** secret carries `serving_key.rotated_from` naming the predecessor, plus a `rotation_grace` marker that stays true only while the predecessor is still valid. A request presenting the **old** secret is attributed to the predecessor's own kid, with no lineage and no marker, because that key was not rotated from anything.

So "how many callers have migrated?" is a question the ledger answers directly, by counting rows per `serving_key.kid`.

Your budgets do not double. The successor inherits the predecessor's owner node, so both secrets resolve to the same tenancy node and the same limit instances; a grace window widens who can call, not what they may spend.

```json
{
  "decision": {
    "governance": {
      "rotation_grace": true,
      "serving_key": {
        "kid": "vk_xXxNzkjKvCAX",
        "node": "org:acme/team:ml/user:dev1",
        "rotated_from": "vk_FEKZdMs2UQRi"
      }
    }
  }
}
```

The lineage names public kids and no secret material. Keys are stored as SHA-256 hashes, and the `ttv_` prefix is stripped before any upstream forward, so a provider does not see a TokenTriage key. Every rotation is also written to the durable audit log with actor `cli`.

One flag matters if you run more than one replica. Point `--config` at a config whose
`shared_state.postgres` tier is enabled and the rotation also writes to the cross-replica
revocation mirror, so the successor and the predecessor's new state reach the other replicas
within the published propagation bound. Without `--config` the command stays local and opens no
connection. If Postgres is configured but unreachable, the rotation still commits locally, warns
on stderr that it was not propagated, and does not retry, so re-run it when Postgres is back.

<NextSteps items={[
  { title: 'Virtual keys', href: '/docs/build/governance-keys', description: 'The whole lifecycle behind this recipe: what is stored, how scopes are checked, and what expiry does.' },
  { title: 'Governance', href: '/docs/build/governance', description: 'The enforcement model these keys are bound to, and where to go next.' },
  { title: 'Ledger field reference', href: '/docs/reference/ledger-fields', description: 'Every row field, including serving_key.rotated_from and rotation_grace.' },
  { title: 'CLI reference', href: '/docs/reference/cli', description: 'Full syntax for tokentriage keys mint / list / revoke / rotate and their flags.' }
]} />
