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.
On this page
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, and the tree a key points into by the tenancy 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 sets the two side by
side.
Minting a key
Minting is one command. It prints the key id, an expiry when you set one, and the secret.
$ 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 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.
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:
$ 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:
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.
$ 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:
$ 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.
Revoking, expiry, and the three key states
Revocation is a state change, not a deletion.
$ 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.
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.
$ 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.
A full /tt/v1/me response, with the chain and live limit counters
$ curl -H "Authorization: Bearer $TT_KEY" http://127.0.0.1:8790/tt/v1/me{
"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}
]
}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:
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.
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.
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. Who is allowed to mint one, and from which surface, is who can administer governance and delegated 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.