Skip to main content
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. 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.
Two things the command refuses at mint time: an ingest token with more than one team, and a team id outside the charset.
The flags, as vexlo token create -h prints them:
-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:
A relay daemon forwarding under an environment-pinned token therefore runs with -environment <that value>; see The relay daemon. 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).
vexlo token list prints every token, active or revoked. last_used_at is written at most once per 60 seconds per token.

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:

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.
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.

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.
GET /v1/whoami answers every caller identically, with role read and the configured team:
GET /v1/capabilities reports "auth_mode": "none" in this mode; the body is on Identity and bootstrap. No-auth mode and header-trust mode together is a startup error:

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.
A request missing either header, or carrying neither, is 401; a wrong secret is a different 401:
-header-trust without a secret is a startup error:
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".