> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vexlo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication and tokens

> Mint, list, and revoke API tokens, read a token's scope back from the server, and run the two modes that replace tokens: no-auth and header-trust.

Every route on the `/v1` API except `GET /v1/capabilities` and `/config.json` requires an
API token in an `Authorization: Bearer` header. A token has one role: `ingest` tokens write
records, heartbeats, and capture policy; `read` tokens query. `GET /v1/whoami` is the one
authenticated route either role can call. The relay daemon holds an ingest token. The UI, the
MCP server, and any script that reads each hold a read token. Rate-limit numbers are on
[Conventions](https://docs.vexlo.ai/api/conventions.md).

Flags are spelled with a single dash, as `-h` prints them; the binary accepts both spellings.

## Token shape

A token is `vxlo_<role>_<token_id>_<secret>`: `role` is `ing` or `read`; `token_id` is 13
lowercase base32 characters, the non-secret lookup key; `secret` is 43 base64url characters, 256
random bits. The server stores a SHA-256 hash of the secret and compares in constant time. The
full string is printed once, at `vexlo token create`, and cannot be read back: `vexlo token list`
shows every column but the hash.

Team is derived from the token, never declared by a client. An ingest token binds exactly one
team, and every row its traffic produces inherits that team. A read token spans one or more
teams (its team-set); `?team=` on a read route narrows the set and never widens it. Team ids
match `^[a-z0-9-]{1,64}$`.

## Mint a token

`vexlo token` works directly on the server storage directory: `-data-dir` (or
`VEXLO_DATA_DIR`) names it, default `~/.vexlo/data`. `vexlo serve` falls back to the same
default, so with no flags anywhere both sides use one directory. `serve` never reads the
`-data-dir` flag itself: a token minted under an explicit `-data-dir` reaches the server only
when `serve` names that directory too, through `VEXLO_DATA_DIR` or through `-db-path` and
`-value-store-dir`. The server does not have to be running. These runs name a scratch
directory, `data`.

```text theme={null}
$ vexlo token create -data-dir data -role ingest -team delivery \
    -service delivery-service -environment dev -label "relay on host-a"
token_id: wfxuppcwaepj4
token:    vxlo_ing_wfxuppcwaepj4_DndgZ4yW0K3MpEwq-OMzyO7ZCGVXVvPs5r8DWkaeMl0
This token is shown once and cannot be recovered — store it now.
```

```text theme={null}
$ vexlo token create -data-dir data -role read -team delivery,platform -label "mcp read token"
token_id: dp35s4pf6hydq
token:    vxlo_read_dp35s4pf6hydq_Vlx7QuCaF_uFLsPaPcqDJpVR0EzcA2CmiOxN2sjJcx8
This token is shown once and cannot be recovered — store it now.
```

Two things the command refuses at mint time: an ingest token with more than one team, and a team
id outside the charset.

```text theme={null}
$ vexlo token create -data-dir data -role ingest -team a,b
vexlo: token create: auth: create token: an ingest token's --team accepts exactly one value, got 2
```

```text theme={null}
$ vexlo token create -data-dir data -role ingest -team Team-Checkout
vexlo: token create: auth: create token: auth: team id "Team-Checkout" contains 'T', want only [a-z0-9-]
```

The flags, as `vexlo token create -h` prints them:

```text theme={null}
Usage of token create:
  -data-dir string
    	server storage directory (env VEXLO_DATA_DIR, default ~/.vexlo/data)
  -environment string
    	optional comma-separated environment-name pin
  -label string
    	optional free-text label, never used in auth logic
  -role string
    	token role: ingest or read (required)
  -service string
    	optional service-name pin
  -team string
    	team id(s), required — a single value for an ingest token, comma-separated for a read token
vexlo: flag: help requested
```

> [!NOTE]
>
> `-environment` accepts a comma-separated list at mint time, but a token pinned to more than one
> environment cannot ingest today: every `POST /v1/records`, `POST /v1/heartbeat`, and
> `PUT /v1/policy` it sends is answered `500 internal_error`. Pin one value.

### Pins

An ingest token carries two optional pins. `-service <name>` restricts the token to one declared
service: a batch whose records declare another service, or a `PUT /v1/policy?service=` naming
another service, is `422 service_scope_mismatch`. `-environment <name>` restricts it to one
environment: a `POST /v1/records` whose `X-Vexlo-Environment` header carries another value is
`422 environment_scope_mismatch`, and so is a batch that sends no header at all:

```text theme={null}
$ curl -s -i -X POST -H "Authorization: Bearer $INGEST_TOKEN" \
    -H "Content-Type: application/json" -d '[]' http://127.0.0.1:18100/v1/records
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
X-Request-Id: c189b228fd6477c9
Date: Mon, 14 Sep 2026 08:50:16 GMT
Content-Length: 116

{"error":{"code":"environment_scope_mismatch","message":"token's environment_scope pin excludes this environment"}}
```

A relay daemon forwarding under an environment-pinned token therefore runs with
`-environment <that value>`; see [The relay daemon](https://docs.vexlo.ai/server/relay-daemon.md). `POST /v1/heartbeat`
enforces neither pin. The pins restrict what a token can write; they say nothing about reads.

## Revoke and list

`vexlo token revoke <token_id>` sets `revoked_at`. Revocation is checked on every request, so it
takes effect on the next one. Revoking a token a relay daemon still holds costs records: the
daemon drops every batch the server answers `401`, rather than keeping them on disk
([What it sends](https://docs.vexlo.ai/server/relay-daemon.md#what-it-sends)).

```text theme={null}
$ vexlo token revoke -data-dir data ouy7xcixo7pmw
revoked token ouy7xcixo7pmw
```

```text theme={null}
$ curl -s -i -H "Authorization: Bearer $REVOKED_TOKEN" http://127.0.0.1:18100/v1/whoami
HTTP/1.1 401 Unauthorized
Content-Type: application/json
X-Request-Id: 79cd03ef8e7636f6
Date: Mon, 14 Sep 2026 08:53:23 GMT
Content-Length: 63

{"error":{"code":"unauthenticated","message":"token revoked"}}
```

`vexlo token list` prints every token, active or revoked. `last_used_at` is written at most once
per 60 seconds per token.

```text theme={null}
$ vexlo token list -data-dir data
token_id: wfxuppcwaepj4
  role:              ingest
  teams:             delivery
  service_scope:     delivery-service
  environment_scope: dev
  label:             relay on host-a
  created_at:        2026-09-14T08:49:05Z
  last_used_at:      2026-09-14T08:51:10Z
  revoked_at:        (active)
... 18 lines omitted
token_id: ouy7xcixo7pmw
  role:              ingest
  teams:             delivery
  service_scope:     (unpinned)
  environment_scope: prod,staging
  label:             (none)
  created_at:        2026-09-14T08:49:05Z
  last_used_at:      2026-09-14T08:50:16Z
  revoked_at:        2026-09-14T08:53:23Z
```

## Read a token's scope back

`GET /v1/whoami` accepts either role and reflects the validated token. `service_scope` and
`environment_scope` are `null` when the token carries no such pin, whatever its role:
`vexlo token create` takes `-service` and `-environment` for a read token as well, and
`whoami` then reports them. The read token below was minted with neither pin:

```text theme={null}
$ curl -s -i -H "Authorization: Bearer $READ_TOKEN" http://127.0.0.1:18100/v1/whoami
HTTP/1.1 200 OK
Content-Type: application/json
X-Ratelimit-Limit: 20
X-Ratelimit-Remaining: 39
X-Ratelimit-Reset: 1
X-Request-Id: 4417f20c1e331770
Date: Mon, 14 Sep 2026 08:49:33 GMT
Content-Length: 97

{"role":"read","team_ids":["delivery","platform"],"service_scope":null,"environment_scope":null}
```

```text theme={null}
$ curl -s -i -H "Authorization: Bearer $INGEST_TOKEN" http://127.0.0.1:18100/v1/whoami
HTTP/1.1 200 OK
Content-Type: application/json
X-Ratelimit-Limit: 20
X-Ratelimit-Remaining: 39
X-Ratelimit-Reset: 1
X-Request-Id: 011f47d461150737
Date: Mon, 14 Sep 2026 08:49:33 GMT
Content-Length: 105

{"role":"ingest","team_ids":["delivery"],"service_scope":"delivery-service","environment_scope":["dev"]}
```

## Refusals

A missing header, an unknown token, and a revoked token are each `401 unauthenticated` with a
distinct message. An ingest token on a read route is `403 forbidden_role`. A read token asking
for a trace outside its team-set gets `404`, the same answer as a trace that does not exist.
Rate-limit headers are absent from 401 responses.

```text theme={null}
$ curl -s -i http://127.0.0.1:18100/v1/traces
HTTP/1.1 401 Unauthorized
Content-Type: application/json
X-Request-Id: 6cd923a9cdb0f4b1
Date: Mon, 14 Sep 2026 08:49:33 GMT
Content-Length: 78

{"error":{"code":"unauthenticated","message":"missing Authorization header"}}
```

```text theme={null}
$ curl -s -i http://127.0.0.1:18100/v1/traces \
    -H "Authorization: Bearer vxlo_read_0000000000000_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
HTTP/1.1 401 Unauthorized
Content-Type: application/json
X-Request-Id: d5fc38cdf489f29e
Date: Mon, 14 Sep 2026 08:49:33 GMT
Content-Length: 63

{"error":{"code":"unauthenticated","message":"unknown token"}}
```

```text theme={null}
$ curl -s -i -H "Authorization: Bearer $INGEST_TOKEN" http://127.0.0.1:18100/v1/traces
HTTP/1.1 403 Forbidden
Content-Type: application/json
X-Ratelimit-Limit: 20
X-Ratelimit-Remaining: 38
X-Ratelimit-Reset: 1
X-Request-Id: d4f4be369cb3b07d
Date: Mon, 14 Sep 2026 08:49:33 GMT
Content-Length: 104

{"error":{"code":"forbidden_role","message":"token role \"ingest\" cannot call a \"read\"-only route"}}
```

The mirror case is not a `403`. A read token on an ingest route is answered `500`, with code
`internal_error` and the message `authentication failed`: the same answer on
`POST /v1/records`, `POST /v1/heartbeat`, and `PUT /v1/policy`.

```text theme={null}
$ curl -s -i -X POST -H "Authorization: Bearer $READ_TOKEN" \
    -H "Content-Type: application/json" -d '[]' http://127.0.0.1:18100/v1/records
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
X-Request-Id: 6b4e3fa6ef050c2d
Date: Mon, 14 Sep 2026 08:49:33 GMT
Content-Length: 70

{"error":{"code":"internal_error","message":"authentication failed"}}
```

## No-auth mode

`vexlo serve -no-auth` disables the token system. Three things follow. The bind address is forced
to loopback whatever `-api-addr` says (the port is kept; `-allow-insecure-http` does not lift
this). Every ingested row and every query is scoped to one team, `-team` or `VEXLO_TEAM`, default
`default`. Rate limiting is off, so no `X-Ratelimit-*` headers appear on any response.

```text theme={null}
$ vexlo serve -no-auth -api-addr 0.0.0.0:18101 -db-path data-noauth/vexlo.db \
    -value-store-dir data-noauth/values
time=2026-09-14T01:53:41.757-07:00 level=INFO msg="serve: starting" addr=127.0.0.1:18101 requires_tls=false no_auth=true header_trust=false db_path=data-noauth/vexlo.db value_store_dir=data-noauth/values shape_retention_days=7 value_budget_bytes=21474836480
... 3 lines omitted
```

`GET /v1/whoami` answers every caller identically, with role `read` and the configured team:

```text theme={null}
$ curl -s -i http://127.0.0.1:18101/v1/whoami
HTTP/1.1 200 OK
Content-Type: application/json
X-Request-Id: 37f21cbf46861210
Date: Mon, 14 Sep 2026 08:53:43 GMT
Content-Length: 85

{"role":"read","team_ids":["default"],"service_scope":null,"environment_scope":null}
```

`GET /v1/capabilities` reports `"auth_mode": "none"` in this mode; the body is on
[Identity and bootstrap](https://docs.vexlo.ai/api/identity.md). No-auth mode and header-trust mode together is a
startup error:

```text theme={null}
$ vexlo check -no-auth -header-trust -header-trust-secret x \
    -db-path data/vexlo.db -value-store-dir data/values
vexlo: check: vexlo serve: --no-auth and header-trust mode (-header-trust-secret/VEXLO_HEADER_TRUST_SECRET) cannot both be set: --no-auth already grants unauthenticated loopback access
```

## Header-trust mode

Header-trust mode is for a read API behind a reverse proxy that has already authenticated a
person and knows their team. It is read-only: ingest always authenticates with a bearer token.
Start the server with `-header-trust` and `-header-trust-secret <value>` (or `VEXLO_HEADER_TRUST`
and `VEXLO_HEADER_TRUST_SECRET`). The proxy injects two headers on every request it forwards:
`X-Vexlo-Team: <id>[,<id>...]`, the team-set, and `X-Vexlo-Trust-Secret: <value>`, the shared
secret, which the server checks against a stored SHA-256 hash in constant time. The headers are
consulted only when `Authorization` is absent; a request that carries a bearer token is validated
as a bearer token.

```text theme={null}
$ vexlo serve -api-addr 127.0.0.1:18102 -header-trust -header-trust-secret proxy-shared-secret \
    -db-path data-ht/vexlo.db -value-store-dir data-ht/values
time=2026-09-14T01:53:44.837-07:00 level=INFO msg="serve: starting" addr=127.0.0.1:18102 requires_tls=false no_auth=false header_trust=true db_path=data-ht/vexlo.db value_store_dir=data-ht/values shape_retention_days=7 value_budget_bytes=21474836480
... 5 lines omitted
```

```text theme={null}
$ curl -s -i -H "X-Vexlo-Team: delivery" -H "X-Vexlo-Trust-Secret: proxy-shared-secret" \
    http://127.0.0.1:18102/v1/whoami
HTTP/1.1 200 OK
Content-Type: application/json
X-Ratelimit-Limit: 20
X-Ratelimit-Remaining: 39
X-Ratelimit-Reset: 1
X-Request-Id: da78f28f33f41022
Date: Mon, 14 Sep 2026 08:53:46 GMT
Content-Length: 86

{"role":"read","team_ids":["delivery"],"service_scope":null,"environment_scope":null}
```

A request missing either header, or carrying neither, is `401`; a wrong secret is a different
`401`:

```text theme={null}
$ curl -s -i -H "X-Vexlo-Team: delivery" http://127.0.0.1:18102/v1/whoami
HTTP/1.1 401 Unauthorized
Content-Type: application/json
X-Request-Id: da586c33bd4651cd
Date: Mon, 14 Sep 2026 08:53:46 GMT
Content-Length: 114

{"error":{"code":"unauthenticated","message":"header-trust mode requires X-Vexlo-Team and X-Vexlo-Trust-Secret"}}
```

```text theme={null}
$ curl -s -i -H "X-Vexlo-Team: delivery" -H "X-Vexlo-Trust-Secret: wrong" \
    http://127.0.0.1:18102/v1/whoami
HTTP/1.1 401 Unauthorized
Content-Type: application/json
X-Request-Id: ab924771bee6b165
Date: Mon, 14 Sep 2026 08:53:46 GMT
Content-Length: 79

{"error":{"code":"unauthenticated","message":"X-Vexlo-Trust-Secret mismatch"}}
```

`-header-trust` without a secret is a startup error:

```text theme={null}
$ vexlo check -header-trust -db-path data/vexlo.db -value-store-dir data/values
vexlo: check: -header-trust is set but -header-trust-secret (or VEXLO_HEADER_TRUST_SECRET) is empty — header-trust mode needs a shared secret to check the proxy's own X-Vexlo-Trust-Secret against
```

Rate limiting in this mode keys on the resolved `X-Vexlo-Team` value in place of a token id.
`GET /v1/capabilities` reports `"auth_mode": "header_trust"`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.