# Security & secrets

What a leaked config file would contain, who can do what, and why the credential bootstrap looks inconvenient on purpose.

Source: https://tokentriage.com/docs/operate/security

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

A gateway sits between your applications and every provider you pay, which makes it the single
most attractive file on your network. Two design choices follow from taking that seriously, and
most of this page is their consequences.

The first: **a deployment you configure yourself stores no secret value.** Configuration carries
names and references, so a leaked config file leaks the shape of your deployment and nothing you
would have to rotate. A hosted gateway can hold a provider key on your behalf, which is a different
mode with a different answer, and it gets one later on this page.

The second: **nothing is open by default and nothing is pre-created.** No credential exists
until you make one, which means there is no default to forget to change and nothing in the
artifact to leak.

This page is the reasoning. [Production hardening](/docs/operate/hardening) is the checklist
that acts on it.

## What a config file contains

Every credential field in the configuration holds an environment variable **name**:

```yaml
upstreams:
  anthropic:
    base_url: https://api.anthropic.com
    dialect: anthropic
    api_key_env: ANTHROPIC_API_KEY # a name, never a value
```

The loader refuses an inline secret where a reference belongs, so a config carrying a key does
not become the running config. The same rule reaches the deployment layers: the Helm chart renders
zero `Secret` objects and mounts yours by reference, the Terraform module takes ARNs resolved on
the instance, and the Compose overlay reads an `env_file` you fill and keep out of version
control.

The admin API holds the line too. `GET /config/running` returns the file's bytes verbatim,
comments and key order intact, and there is no secret in that body to redact because none was
ever written.

Credentials the product mints for itself are hashed on the way to disk. Bearer tokens and share
tokens are stored as SHA-256, local dashboard users as argon2id, and virtual keys as SHA-256 —
each shown once at mint and unrecoverable afterwards. A key's prefix is stripped before the
request is forwarded upstream.

<Callout type="key">
Nothing in a self-managed deployment's values, manifests, release object or CI holds a
credential. The only place a plaintext ever exists is the terminal you minted it on.
</Callout>

## Saved provider connections, the mode that does store a secret

A hosted gateway can hold your provider key so that your applications do not, and a storage claim
only means something once you say which mode it describes. A saved connection's secret is written
once, sealed under its own data key, wrapped by a customer-managed KMS key in TokenTriage's cloud
account, and read back by no API, export or log. Later reads show the connection's label, its
version and its status, and nothing derived from the secret, not even its length. Your applications
then carry a TokenTriage runtime key and no provider secret at all.

Request-supplied mode is the other setting, and it keeps the guarantee this page opens with: the
caller sends a provider key per request and no copy is kept. Which of the two applies is gateway
configuration rather than a fallback a request can select. [Saved provider
connections](/docs/build/provider-connections) is the procedure.

## Who can do what

Roles are a static three-level matrix, ranked so each subsumes the one below it:

| Role | Grants |
|---|---|
| `viewer` | every read endpoint, exports included — an export is a read |
| `operator` | viewer, plus config-emitting writes, feedback, alert silences, cache purge, provider probes, replay, saved views and schedules, reconcile imports, governance node and revoke |
| `admin` | operator, plus user and token management, retention deletion, governance key mint and rotate, share tokens |

Denials are legible rather than evasive. A request with no credential where one is required gets
`401`; a principal below a route's floor gets `403`, not a `404` pretending the route does not
exist. Any unmatched path under the versioned API fails closed before reaching a handler.

Three paths are open, and the list is short enough to audit: `/health`, because the
kubelet needs no credential to ask whether a process is alive; `/metrics`, because a scrape
carries no secret; and the dashboard's static assets, because every API call the interface makes
is gated anyway.

## The bootstrap, and its honest limitation

The dashboard is secured before it serves anything. It renders, and no login succeeds, until you
mint a credential out of band:

```console
$ kubectl exec -n tokentriage tt-tokentriage-0 -- \
    tokentriage token new --role admin --state-dir /data/.tokentriage/dash
token id: 40bd4df95ae0d61b  role: admin
This token is shown ONCE and cannot be recovered:
ttk_...
```

The token is effective immediately. The stores are re-read on every verification, so there is
no restart and no propagation delay. The `401 → mint → 200` transition is what the bundled
`helm test` asserts, without a credential, on every install and upgrade.

<Callout type="warning">
The entire bootstrap depends on `kubectl exec` into pod zero. **Clusters that forbid exec cannot
use it**, and there is no workaround today. The designed answer is an opt-in, RBAC-scoped
bootstrap Job, deferred precisely because granting a chart permission to write credentials is
the risk this design exists to remove. Check your platform's policy before you commit to
TokenTriage on it.
</Callout>

Auth modes are chosen with `dashboard.auth.mode`:

- **`token`**, bearer tokens. The chart's default, and the better zero-state choice: no user
  database to seed and one header to present.
- **`users`**, named local users with argon2id password hashes, a session cookie, and a CSRF
  token bound to the session. For teams that want named principals.
- **`off`**, permitted only on a loopback admin bind, and a validation error otherwise. The
  Helm values vocabulary does not include it at all, so an open dashboard on a container
  deployment is not expressible.

Sessions live in memory, so any restart logs every browser out. Minted tokens and users live on
the volume and keep working; you do not re-mint after an upgrade or a restore.

## The air-gap consent gate

Every outbound tier is gated on one string. Shared state, an L3 cache, an external embeddings
API, OTLP export, and a guardrail judge or sidecar are each refused unless their own config
block carries `external: "allow"`.

```yaml
shared_state:
  redis:
    external: "allow" # you type this; no deployment layer types it for you
    addr: redis.data.svc:6379
    password_env: TT_SHAREDSTATE_REDIS_PASSWORD
```

The gate is a property of the binary rather than of a deployment layer, which is what makes it
hard to un-gate by accident: the chart copies your consent string through verbatim and refuses
to render an enabled backend without it, and CI asserts the set of hosts the product dials is a
subset of the upstreams you configured. A default install dials nothing.

The guardrails tier goes further. Its core and every embedded detector are import-restricted —
no networking, no process execution, no TLS, no system calls, no plugins. A test sweeps the
whole subtree, so an embedded detector cannot reach the network even by mistake. [Air-gap &
known limits](/docs/guardrails/air-gap-and-limits) covers which detectors are embedded and which
leave the process.

Guardrail consent is necessary but does not enable unsupported capabilities. Stock sidecar
activation remains rejected pending runtime/model qualification; positive judge hard caps require
a proven reservation bound. See the [current activation table](/docs/guardrails/air-gap-and-limits).

## What is recorded

Every attempted mutation writes an append-only audit row carrying the actor, the action, a
content hash of any diff, and the configuration generation it applied against. Denied requests
are recorded too, and a credential that appeared in a request path is redacted before the row is
written.

Retention deletions are audited the same way, one row per file with its reason:

```json
{"v":1,"ts":"2026-08-10T12:55:42.0590646Z","actor":"system","role":"","origin":"cli","action":"retention.delete","resource":"decisions-20260701T031500.jsonl.gz","summary":"deleted rotated log decisions-20260701T031500.jsonl.gz (19647 bytes, max_age)","result":"ok"}
```

Verbatim prompt and response capture is a separate decision and is **off by default**. Turned
on, it sits behind its own privileged scope and writes one audit row per read attempt, because
the bytes of a prompt are a different class of data from a row about it.

<Details summary="Where each credential lives, and what to do when one leaks">

| Credential | Store | At rest | Rotate with |
|---|---|---|---|
| dashboard bearer token | `dash/tokens.json` | SHA-256 | `tokentriage token revoke <id>` then `token new` |
| local dashboard user | `dash/users.yaml` | argon2id | `tokentriage user passwd <name>` |
| share link token | `dash/share-tokens.json` | SHA-256 | revoke the share |
| virtual key | the governance store | SHA-256 | rotate with a grace window, or revoke |
| provider key | your secret manager | not ours to hold | rotate at the provider, update the `Secret` |

Revocation of a dashboard token is immediate, because the store is consulted on every
verification. Virtual-key revocation across a fleet is not instantaneous and publishes its
bound. [Governance across replicas](/docs/build/governance-distributed) covers the number and
what it means.

A leaked *config file* is the case that needs no action beyond review: it holds hostnames,
environment-variable names and policy, and nothing that authenticates anything.

</Details>

## What this does not cover

The steps to apply all of this, covering TLS, the admin plane, the rendered security context,
network policy and OpenShift, are [production hardening](/docs/operate/hardening). Verifying the artifact
you downloaded is on [install air-gapped](/docs/operate/air-gap). Limiting what a *tenant* may
spend is a different subject from authenticating an operator, and lives under
[governance](/docs/build/governance).

<NextSteps items={[
  {title: "Production hardening", href: "/docs/operate/hardening", description: "The checklist that turns these choices into a deployment, with the rendered defaults."},
  {title: "Virtual keys", href: "/docs/build/governance-keys", description: "Credentials for the callers rather than the operators: minting, rotation, and revocation."},
  {title: "Install air-gapped", href: "/docs/operate/air-gap", description: "The consent gate in practice, and what you can verify about a downloaded artifact."},
  {title: "Errors & problem+json", href: "/docs/reference/errors", description: "The error contract behind the 401 and 403 distinction, with request ids."}
]} />
