# Virtual keys

Mint a ttv_ key, scope it, rotate it with a grace window, and revoke it, knowing what is stored and what is not.

Source: https://tokentriage.com/docs/build/governance-keys

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

You want to hand a team, a service, or an outside contractor a credential of its own:
something you can scope down, watch, and take back on a Tuesday afternoon without touching
anything else. Handing out your provider key does none of that. It has no owner, no scope,
and no way back except rotating it for everybody at once.

A TokenTriage **virtual key** is that credential. It looks like `ttv_` followed by 49
characters, it rides in the `Authorization` header like any bearer token, and it binds the
request to a place in your tenancy tree. Every limit hanging off that place then applies,
and the ledger row carries the key's public id.

This page follows one key from mint to revocation: what the secret is, what gets stored,
how scopes are checked, what a grace window buys you, and the three states a key can be
in. The limits are covered by [the six limit types](/docs/build/governance-limits), and
the tree a key points into by [the tenancy hierarchy](/docs/build/governance-hierarchy).

## What a virtual key binds to

If you have used Portkey, the term already means something to you, and it means something
else here. The two do not map onto each other, so a migration that assumes they do goes
wrong quietly.

A Portkey virtual key is an alias for a provider credential Portkey stores on your behalf.
A `ttv_` key stores no provider credential at all. It names a **tenancy node**, a path like
`org:acme/team:ml/user:dev1`, and carries the scopes that node's holder may reach. Whose
provider key pays for the request is a separate question, answered by routing, and covered
later on this page. [Migrating from Portkey](/docs/migrate/portkey) sets the two side by
side.

<Callout type="key">

A `ttv_` key is a tenant name, not a stored secret. It carries no provider credential, and
only a SHA-256 hash of the key itself is kept at rest, so the secret cannot be read back
out of TokenTriage by anyone, including TokenTriage.

</Callout>

## Minting a key

Minting is one command. It prints the key id, an expiry when you set one, and the secret.

```console
$ tokentriage keys mint --owner org:acme/team:ml/user:dev1 --label dev1-laptop --expiry 2160h
kid:     vk_FEKZdMs2UQRi
expires: 2026-11-07T20:41:14Z
This secret is shown ONCE and cannot be recovered — copy it now:
ttv_EeNpxgdtQYWPmdVoBJQ0r3pi…
```

That transcript is generated output from a local sandbox whose key store has since been
destroyed; the secret's tail is elided, and the rest is verbatim. The tenancy-node flag is
`--owner`. The product's own operator guide names `--node` in two places, and no such flag
exists, which [the CLI reference](/docs/reference/cli) also records.

The reveal happens once because there is nowhere to read it from afterwards. What the
store holds is the SHA-256 hash of the secret, the `kid`, a four-character `stub` for
matching against the copy the holder kept, the label, the owner node, the scopes, the
state, and the expiry. No column can hold plaintext. Lose the secret and the recovery is a
rotation, not a lookup.

The `kid` is the public half. It is safe to log, print, and put in a ticket, and it is
what every audit row and every ledger row names.

<Callout type="warning">

Only the model globs are checked at mint. `--providers`, `--endpoints` and `--owner` are
carried through verbatim, so `--endpoints chats` mints cleanly and yields a key that gets a
403 on every request, and `--owner org:acme/team:mI` mints a key whose chain resolves to
nodes that do not exist, leaving only `__global` limits attached. Confirm a new key with
`tokentriage keys list` and one call to `GET /tt/v1/me` before you hand it over; the repair
is to revoke and mint again, because there is no surface for editing scopes in place.

</Callout>

## Scopes, and which gate checks which

A key that can reach every model is a budget, not a boundary. Scopes are the boundary, and
they are set at mint:

```console
$ tokentriage keys mint --owner org:acme/team:search --label search-svc --models 'gpt-4o-mini,gpt-4o'
kid:     vk_7gdbdNH7UCoZ
This secret is shown ONCE and cannot be recovered — copy it now:
ttv_DMMgxbxzRDBqjNrMb19QD2nY…
```

Four dimensions ship, and they match by different rules.

| Dimension | Flag | Matching | Left unset |
|---|---|---|---|
| models | `--models` | `path.Match` globs | unrestricted |
| providers | `--providers` | exact match on the descriptor provider id | unrestricted |
| endpoints | `--endpoints` | exact match on the endpoint kind | unrestricted |
| passthrough | `--allow-passthrough` | boolean | `false`, so a scoped key is stopped at an unregistered endpoint |

Model globs use `path.Match`, where `*` matches inside one path segment and stops at a `/`.
A Fireworks model id like `accounts/fireworks/models/llama-v3` is matched by neither
`llama*` nor `accounts/*` nor a bare `*`; it takes `accounts/*/models/llama*`. A glob that
under-matches over-restricts the key, which is the safer direction to fail in, and it is
still a surprise at 2am.

**Where each dimension is checked matters more than how it matches.** The endpoint scope
is checked at StageA, before the request body is read and before any limit is evaluated.
Model and provider are checked after routing, against the **served** model and provider,
and before cache dispatch. So a `model_rewrite` rule or a cascade fallback cannot walk a
request into a model the key does not hold, and a warm cache entry cannot serve where a
live call would have been stopped. Anonymous traffic is not scope-checked at all, because
there is no key to check against.

A violation is an HTTP 403 that names the dimension and nothing more:

```console
HTTP/1.1 403 Forbidden
Content-Type: application/json
X-Tt-Refusal-Type: scope_violation
X-Tt-Request-Id: tt_l3dgk5yfk4evftdngjxjqwkhom

{"error":{"type":"scope_violation","message":"request refused: model outside key scope","refused_by":{"node":"org:acme/team:search"}}}
```

That is the `search-svc` key above asking for `o3-mini`. The body names the violated
dimension and the holder's own node. It does not list what the key is allowed to reach,
which would hand an attacker an inventory.

## Rotating with a grace window

Replacing a credential in a live fleet is the operation that breaks things, because the
old one usually stops working the instant the new one starts. Rotation with a grace window
keeps both valid for a stated period.

```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-YPyJsA3Tpq…
```

The listing straight afterwards is where the mechanism becomes legible:

```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_7gdbdNH7UCoZ active   org:acme/team:search     DMMg     never                search-svc
vk_xXxNzkjKvCAX active   org:acme/team:ml/user:dev1 Fodk     2026-11-07T20:41:14Z dev1-laptop
```

Read the three rows. Both `dev1-laptop` keys are `active`. The predecessor's `EXPIRES` was
rewritten to 24 hours from the rotation, `2026-08-10T20:47:20Z`. The successor inherited
the predecessor's original expiry, `2026-11-07T20:41:14Z`, along with its label, owner
node and scopes. That gap is the migration window: ship the successor, let callers pick it
up, and the predecessor lapses on its own schedule.

A rotation can shorten a key's life and cannot extend it, because the grace deadline is
clamped down to the predecessor's own expiry when that falls sooner. The `OWNER` column is
padded to 24 characters and `org:acme/team:ml/user:dev1` is 26, so those two rows run into
the next column; that is the shipped output.

One thing about that output is worth knowing before you rely on it. `keys rotate` prints no
`expires:` line even when the successor inherits one, so the terminal makes a 90-day
successor look like it lasts forever. `keys list` is where the expiry shows up.

<Callout type="warning">

The dashboard and the management API always rotate with grace `0`: the successor is minted
and the predecessor is switched off in the same transaction, so callers still holding it
start getting 401s immediately. A grace window is reachable only from the CLI's `--grace`
flag. Rotate from the CLI whenever in-flight callers matter.

</Callout>

## Revoking, expiry, and the three key states

Revocation is a state change, not a deletion.

```console
$ tokentriage keys revoke vk_7gdbdNH7UCoZ
revoked vk_7gdbdNH7UCoZ
```

The row stays in `keys list` with `STATE` set to `revoked`, so the audit trail and the
lineage survive. The gate stops honouring the key on the next snapshot swap, which is one
rebuild rather than a poll interval.

A key is in one of three states: `active`, `revoked`, or `expired`. Expiry is enforced
live at the gate: a key past its `expires_at` stops resolving the moment it lapses,
whatever its stored state says. The state column is a durability record, not the enforcer.

That split has a visible consequence. The sweep that would move a lapsed key from `active`
to `expired` has no production caller anywhere in the binary, so nothing ever runs it. A
key whose expiry passed last month still lists as `active` and still writes no expiry
entry to the audit trail. It is turned away at the gate on every request regardless, so
the enforcement is intact and the listing is what misleads you. It is one of the items on
[what governance does not do](/docs/build/governance-gaps).

## Whether a key is required at all

`governance.require_key` decides how anonymous traffic is treated. Its default is `off`,
and that default is applied only when `governance.enabled` is true.

| Value | Anonymous traffic | A presented `ttv_` that does not resolve | Governance arms at boot |
|---|---|---|---|
| `off` (default) | admitted | 401 `invalid_key` | not on its own |
| `optional` | admitted | 401 `invalid_key` | yes |
| `required` | 401 `invalid_key` | 401 `invalid_key` | yes |

The first two rows are identical, and that is the finding: `off` and `optional` produce
the same behaviour inside the engine. Both let anonymous requests through and both turn
away a presented-but-unresolvable key. What differs is arming. With `off`, no limits, and
`enabled: false`, governance stays inert and the gate is not installed; add one limit or
set `enabled: true` and it arms anyway. `optional` arms it on its own.

The trap in that table is the middle column. Once governance is armed, `require_key: off`
does not mean keys are ignored: a revoked or unknown `ttv_` still gets a 401, because a bad
credential is not quietly downgraded into anonymous traffic and, with it, into somebody
else's tenancy.

Every 401 in that table carries the same body, whichever cell produced it. The capture that
follows is anonymous traffic under `required`, with no `Authorization` header sent at all.

```console
$ curl -i -X POST http://127.0.0.1:8788/v1/chat/completions \
    -H 'content-type: application/json' -d '{"model":"gpt-4o-mini","messages":[]}'
HTTP/1.1 401 Unauthorized
Content-Type: application/json
X-Tt-Refusal-Type: invalid_key
X-Tt-Request-Id: tt_7niasub57sgh4gotj223c2y4f4

{"error":{"type":"invalid_key","message":"invalid or unknown key"}}
```

The 401 withholds on both surfaces. There is no `refused_by` block on the wire, and the
row for that request carries none either, so neither names a node. Under `required`, one
further case exists: when the key store cannot be loaded at all, the answer is a 503
rather than a 401, so a cold replica does not report every caller as unknown.

The loopback ports in the captures on this page are two sandbox configs talking to a fake
upstream; yours is whatever `server.listen` names.

## What a key holder can see about itself

A tenant holding a key can ask what it is allowed to do without an admin round trip.
`GET /tt/v1/me` on the data-plane port answers for the caller and for nobody else.

<Details summary="A full /tt/v1/me response, with the chain and live limit counters">

```console
$ curl -H "Authorization: Bearer $TT_KEY" http://127.0.0.1:8790/tt/v1/me
```

```json
{
  "kid": "vk_FEKZdMs2UQRi",
  "node": "org:acme/team:ml/user:dev1",
  "chain": ["org:acme/team:ml/user:dev1/key:vk_FEKZdMs2UQRi","org:acme/team:ml/user:dev1","org:acme/team:ml","org:acme","__global"],
  "scopes": {"models":null,"providers":null,"endpoints":null,"allow_passthrough":false},
  "limits": [
    {"limit_id":"org_monthly_budget","type":"budget_usd","class":"spend","unit":"usd","node":"org:acme","period":"month","resets_at":"2026-09-01T00:00:00Z","suspended":false,"posture":{"mode":"on","enforce":"hard","fail":"open"},"limit_usd":{"usd":0.000001,"estimated":false,"unpriced":false},"observed_usd":{"usd":0.0000126,"estimated":true,"unpriced":false},"remaining_usd":{"usd":0,"estimated":true,"unpriced":false}},
    {"limit_id":"user_concurrency","type":"concurrency","class":"count","unit":"concurrent-requests","node":"org:acme/team:ml/user:dev1","period":"","suspended":false,"posture":{"mode":"on","enforce":"hard","fail":"open"},"amount":10,"observed":0,"remaining":10},
    {"limit_id":"team_rpm","type":"rpm","class":"count","unit":"requests","node":"org:acme/team:ml","period":"1m0s","suspended":false,"posture":{"mode":"on","enforce":"hard","fail":"open"},"amount":3,"observed":2,"remaining":1},
    {"limit_id":"team_tpm","type":"tpm","class":"count","unit":"tokens","node":"org:acme/team:ml","period":"1m0s","suspended":false,"posture":{"mode":"on","enforce":"hard","fail":"open"},"amount":20000,"observed":0,"remaining":20000}
  ]
}
```

</Details>

Three fields carry most of the value. `chain` is the resolution walk printed out, five
entries deep, deepest first, terminating at `__global`. `scopes` reads `null` on every
dimension for an unrestricted key. Each entry under `limits` reports what has been used and
what is left: `observed` and `remaining` on a count limit, `observed_usd` and
`remaining_usd` on a spend limit, plus `resets_at` on a calendar limit. A caller can back
off before it gets a 429 rather than after. An anonymous request to the same path gets a
401 with an empty body.

## Where the upstream credential comes from

Since the key holds no provider secret, something has to answer whose credential pays. The
answer is set per upstream, not per key.

Start with what does not go out. Any `ttv_`-shaped or `tts_`-shaped value on
`Authorization`, `X-Api-Key`, `Api-Key` or `X-Goog-Api-Key` is removed before the request
is forwarded, on every path, including upstreams with no credential configured, so a
provider sees no TokenTriage credential. A genuine downstream credential that is not
TokenTriage-shaped is left byte-untouched.

What goes out instead is the upstream's own credential, read from the environment variable
named in the config, picked by whichever upstream routing chose:

```yaml
upstreams: # a map of named upstreams
  openai:
    base_url: https://api.openai.com
    dialect: openai
    api_key_env: OPENAI_API_KEY # the NAME of the variable, not the value
  ollama:
    base_url: http://localhost:11434
    dialect: openai
    # no api_key_env → the inbound Authorization is forwarded as-is
```

Per-tenant provider credentials are not a shipped configuration surface. A tenant is a
tree of `{org, teams, users}`, and no node carries a credential field, so you cannot today
attach team A's OpenAI key to team A's subtree. What keeps attribution answerable instead
is `decision.governance.serving_key` on every row that resolved a key, carrying the `kid`,
the `node`, and `rotated_from` for a key that came out of a rotation. Names only, no
secrets. Turning those rows into spend per tenant is
[cost and analytics](/docs/build/cost-analytics).

## Two settings that are read by nothing

A setting that validates and then does nothing is worse than one that is absent, because it
reads as configured. Two of them live under `governance.keys`.

`default_expiry` is parsed at load time, and a malformed duration is a path-named config
error, so it reads as live. Nothing then consumes it. A mint with no `--expiry` gets no
expiry at all, whatever the file says, and both shipped governance example configs set
`90d`. Set the expiry on the command line.

`prefix` is defaulted to `ttv_` and consumed by nothing either. The prefix a mint writes is
a constant in the binary, next to the fixed body length and the checksum that let a scanner
recognise the format without a lookup. A different value in YAML changes nothing about the
keys you mint. Both settings are catalogued on
[what governance does not do](/docs/build/governance-gaps).

## What this page does not cover

The key, not the ladder it plugs into. Which limits attach to the key's node, and in what
order they resolve, is [the tenancy hierarchy](/docs/build/governance-hierarchy). Who is
allowed to mint one, and from which surface, is
[who can administer governance](/docs/build/governance-access-control) and
[delegated self-serve](/docs/build/governance-self-serve).

The `tts_` tenant tokens named in passing earlier, and the `ttk_` dashboard tokens beside
them, are a separate credential family in a store of their own, covered by the
access-control page. The full field list behind a 403 or a 401 is
[the refusal wire reference](/docs/build/governance-refusal-reference).

<NextSteps items={[
  { title: 'Rotate a virtual key', href: '/docs/recipes/rotate-a-virtual-key', description: 'The grace-window rotation on this page as a task you can finish today.' },
  { title: 'The tenancy hierarchy', href: '/docs/build/governance-hierarchy', description: 'What org/team/user/key means and how a limit resolves through the chain a key prints on /tt/v1/me.' },
  { title: 'Cap spend per virtual key', href: '/docs/recipes/cap-spend-per-virtual-key', description: 'Attach a budget to the node a key owns, and watch it stop the request.' },
  { title: 'What governance does not do', href: '/docs/build/governance-gaps', description: 'The inert settings from this page, plus every other knob that validates and does nothing.' }
]} />
