Search

↑↓ to move↵ to openesc to closetop 6 documentation results

Book a demoTry TokenTriage

In development · Explore the product preview. Join the launch list or book a walkthrough.

Browse docs

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.

ExplanationLast updated

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.