# Sign in to the dashboard

Three authentication modes, one of which is only valid on loopback. Pick one, mint a credential, and know what reaches disk.

Source: https://tokentriage.com/docs/build/dashboard-auth

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

On your laptop the dashboard needs no credential, because it is bound to loopback and anyone
who can reach it is already you. The moment it binds anything else, that stops being true, and
the binary will not let you pretend otherwise.

This page covers the three modes, which to pick, and how to create a credential in each. Who
can do what once they are in is [roles and permissions](/docs/build/dashboard-roles).

## Pick a mode

`dashboard.auth.mode` takes one of three values.

| Mode | Credential | Pick it when |
|---|---|---|
| `off` | none | the admin listener binds loopback and you are the only user. The default. |
| `token` | a bearer token | one operator, or a script, or a Grafana panel embedding a view |
| `users` | a session cookie backed by a local users file | more than one person, or you want per-person roles and an audit trail with names |

<Callout type="warning">
`off` is valid only while the admin listener binds a loopback address. A non-loopback bind
with `mode: off` fails validation rather than starting an open dashboard, so a container or a
host-network deployment must choose `token` or `users` before it will boot.
</Callout>

Whichever you pick, the mode governs the whole admin plane: the UI, the management API, and
the exports. There is no separate setting for the API.

## Token mode

One shared secret, presented as a bearer token. Set the mode:

```yaml
dashboard:
  enabled: true
  auth:
    mode: token
```

Then mint a token. It is printed once and only its SHA-256 hash reaches disk, so there is no
command that will show it to you again:

```console
$ tokentriage token new --role viewer --label "grafana panel"
token id: df48d71da95e75ea  role: viewer
This token is shown ONCE and cannot be recovered:
ttk_Z-WSSTQBOaiu-lRKMUIFbAwmBAlC2INel0hl99y0gDs
```

That capture is from a throwaway state directory that no longer exists. Yours will differ, and
the value after `ttk_` is the part you keep.

Present it as an `Authorization` header:

```console
$ curl -s -H "Authorization: Bearer ttk_…" \
    http://127.0.0.1:9090/tt/api/v1/capabilities
```

`token list` shows what exists without showing any secret, and `token revoke` takes effect
immediately:

```console
$ tokentriage token list
ID                 ROLE       REVOKED  LABEL
df48d71da95e75ea   viewer     false    grafana panel
```

Give a token the lowest role that does the job, and a label that says what it is for. A
`--ttl` makes it expire on its own, which is the better default for anything handed to a
person rather than baked into a deployment.

## Users mode

Local users with per-person roles. Passwords are hashed with argon2id and sessions are minted
server-side, so the cookie carries no credential material.

```yaml
dashboard:
  enabled: true
  auth:
    mode: users
    session_ttl: 12h        # default: 12h
```

Add a user. The password is read from stdin and never echoed, logged or written:

```console
$ tokentriage user add alice --role operator
Password:
added user "alice" with role operator
```

```console
$ tokentriage user list
NAME                     ROLE       CREATED
alice                    operator   2026-08-10
```

The tree also has `passwd`, `set-role` and `remove`. All of them read and write `users.yaml`
under the dashboard state directory, which defaults to `~/.tokentriage/dash` and is set by
`dashboard.state_dir`. Once the dashboard is running you can do the same work from the Users
panel in Settings, which requires the admin role.

<Callout type="key">
No plaintext credential reaches disk in any mode. Passwords are stored as argon2id hashes,
bearer and share tokens as SHA-256 hashes. A secret exists in memory long enough to be shown
to you once, and then only its hash survives.
</Callout>

Repeated failed logins are throttled per source address before the password check runs, so a
spray costs the attacker time without costing your instance the work of hashing. A sustained
run of failures is recorded in the audit trail.

## What a denial looks like

Two different failures, and telling them apart saves time.

**No credential, on a route that needs one** gets a `401`. Send one.

**A credential whose role is below the route's floor** gets a `403` naming the role that was
required. It is never a `404`: hiding the existence of a route you cannot use would make the
boundary impossible to reason about.

```json
{"type":"/tt/api/problems/forbidden","title":"Forbidden","status":403,"detail":"route not permitted","code":"forbidden","request_id":"req_06754dd4d478f526"}
```

Every denial carries a `request_id` that also appears on the server's log line for the same
request, which is what lets you correlate a user's screenshot with your own logs.

<Details summary="Where the credentials live, and what to back up">

Everything is under `dashboard.state_dir`, default `~/.tokentriage/dash`:

| File | Holds |
|---|---|
| `users.yaml` | user names, roles, argon2id password hashes |
| `tokens.json` | token ids, roles, labels, expiry, SHA-256 hashes |
| the audit log | every attempted write, accepted or rejected, with its actor |
| the event ring | the durable mirror of successful mutations |

None of it is derived, so none of it rebuilds itself. Include the state directory in whatever
backs up your ledger; [backup and restore](/docs/operate/backup-restore) covers the rest of
what a deployment holds.

</Details>

## What can go wrong

**Validation rejects the config with a message about the admin bind.** You have `mode: off`
and a non-loopback `server.admin`. Choose `token` or `users`.

**A minted token does not work.** Check that the mode is `token`; a token presented to an
instance in `users` mode is not a credential it accepts. Check the state directory too, since
`token new` writes to whichever directory it was given and the server reads the one in its
config.

**Nobody can sign in after a restore.** The state directory was not restored with the ledger.
Mint a new token from the host to get back in, then re-add users.

**You need to reach the API from a script and do not want a long-lived secret.** Mint a token
with `--ttl` and re-mint on a schedule, or use a share token if the script only needs one
frozen query. [Share a view](/docs/build/dashboard-sharing) covers the second option.

<NextSteps items={[
  {title: "Roles and permissions", href: "/docs/build/dashboard-roles", description: "What viewer, operator and admin can each do, and which operations are privileged."},
  {title: "Share a view without giving access", href: "/docs/build/dashboard-sharing", description: "A read-only link to one frozen query, for somebody with no account at all."},
  {title: "Harden a deployment", href: "/docs/operate/hardening", description: "The network stance for the admin plane, which this page assumes you have decided."},
  {title: "The management API", href: "/docs/build/dashboard-api", description: "What these credentials authenticate against, and the scopes a token can carry."}
]} />
