GitHubBook a demoStart routing
Browse docs

Rotate a virtual key with zero downtime

Roll a compromised or aging virtual key to a successor without dropping traffic — old and new both resolve through a grace window, and the ledger records the lineage on serving_key.rotated_from.

Last updated

On this page

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 Governance, virtual keys & budgets.
  • 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 — never the secret. List it if you don’t have it:
$ tokentriage keys list
KID             STATE    OWNER                    STUB     EXPIRES              LABEL
vk_9kQ2mX7tP4aZ active   org:acme/team:ml         ab12     never                ml-serving

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.

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

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 — you do not re-specify them.

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 (never longer than its own pre-existing expiry — rotation never extends a key’s life), then goes inactive. A background sweep later drops it from the snapshot and marks its state expired.

Confirm both states while the window is open:

$ tokentriage keys list
KID             STATE    OWNER                    STUB     EXPIRES              LABEL
vk_9kQ2mX7tP4aZ active   org:acme/team:ml         ab12     2026-07-30T12:00:00Z ml-serving
vk_5rD8nB1uL0cQ active   org:acme/team:ml         7f3c     never                ml-serving

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.

$ tokentriage keys rotate vk_9kQ2mX7tP4aZ --grace 0
rotated vk_9kQ2mX7tP4aZ -> vk_5rD8nB1uL0cQ (predecessor revoked immediately)
kid:     vk_5rD8nB1uL0cQ
This secret is shown ONCE and cannot be recovered — copy it now:
ttv_…

Why this is honest and safe

Requests that ride the grace window are attributed to the successor kid, and the row records the predecessor on serving_key.rotated_from in the ledger, alongside a rotation_grace marker. That means an operator can always answer “whose key served this row, and what was it rotated from?” from the ledger itself — TokenTriage does not blur a rotation into a single ambiguous identity.

{
  "decision": {
    "governance": {
      "rotation_grace": true,
      "serving_key": {
        "kid": "vk_5rD8nB1uL0cQ",
        "node": "org:acme/team:ml",
        "rotated_from": "vk_9kQ2mX7tP4aZ"
      }
    }
  }
}

The lineage names public kids only — never secret material (keys live as SHA-256 hashes at rest, and the ttv_ prefix is stripped before any upstream forward, so a provider never sees a TokenTriage key). Every rotation is also written to the durable audit log with actor cli.