> Fetch the index of every Tonbo docs page as Markdown at https://tonbo.dev/llms.txt

# API keys and tokens

Last updated 2 November 2026

Give your program a Tonbo API key before it reaches a database or a stream, and narrow it to what the program uses. A request carries the key itself, or a short-lived access token exchanged from it.

In Node.js, `open()` from `@tonbo/sqlite-ursula/tonbo` takes the key, looks the database up by name and keeps a fresh token in the background. A host that does not run it, or that holds its token in a file, makes the exchange itself. In Python, `sqlite_ursula.open()` takes the database’s URL and a key that acts as you as its `token`, sent directly; a key that acts as an Agent is exchanged first, and the token goes in its place (see [Connect from Python](https://tonbo.dev/docs/databases/python.md#key)). A database’s URL is on its region’s database origin (see [Regions and URLs](https://tonbo.dev/docs/databases/manage.md#regions)), such as `https://db.tonbo.dev`. This page describes where `open()` finds the key, sending a key directly, the exchange, tokens for a web page, what a key can reach, and a reference refresher.

## Opening by name

`open("notes")` takes the key from the first of these that is set, and tries no other:

1. the `apiKey` option;
2. `TONBO_API_KEY`;
3. the login `tonbo login` saves in `${XDG_CONFIG_HOME:-~/.config}/tonbo/credentials.json`. The file must be a regular file with mode `0600`. A symlink, or a file open to group or others, is refused and never read.

An explicit `token` option, or `URSULA_VFS_TOKEN_FILE`, serves the database requests instead of the key. Use one key per process: the process holds one key for its access tokens, so every database it opens is reached as that key’s account, and an open by name with a second key fails.

`resolveDatabase("notes")`, from the same entry, finds the key in the same order and fails with the same errors, without opening the database. It returns `{ url, token }` for another opener, such as Pi’s `openUrsulaPiStorage`: the extension keeps the key’s access tokens fresh, and `token` is `undefined` while they serve, so pass it on as it is.

A failed open rejects with `TonboError`. Its `kind` says why, and its message ends with the fix:

| Kind | When | Fix |
| --- | --- | --- |
| `missing_credentials` | No `apiKey`, no `TONBO_API_KEY` and no login file. | `Set TONBO_API_KEY, or run: tonbo login` |
| `credentials_refused` | The login file is a symlink, not a regular file, or open to group or others. | `Run: chmod 600 <path>`, or replace the file |
| `invalid_api_key` | Tonbo refused the key (`401`), the key cannot read the database (`403`), or the login expired. | A valid `apiKey` or `TONBO_API_KEY`, or `Run: tonbo login`; for a `403`, `Use an API key that can read database "<name>"` |
| `database_not_found` | The key’s account has no database of that name, or it is being deleted. | `Run: tonbo database create <name>` |

A database still being created is waited for. When the Management API is rate limited, down or unreachable, the open retries until its signal fires, then rejects with `UrsulaUnavailableError` and the kind `rate_limited`, `unavailable` or `unreachable`. Every open by name looks the name up through the Management API, so while that API does not answer, open the database by its URL (see [Open by URL](https://tonbo.dev/docs/databases/connect.md#by-url)), which still works.

## Sending the key directly

A key that acts as you is accepted as `Authorization: Bearer` at the Streams and database origins of every region, with no exchange. It reaches exactly what a token exchanged from it reaches, narrowing included (see [What a key can reach](#key-scope)).

- A key that acts as an Agent is never accepted directly. It answers `401` with `application/problem+json` whose `code` is `exchange_required` and whose `token_endpoint` names where to exchange it (see [below](#exchange)).
- Revoking the key stops it within 60 seconds, usually within a few seconds.
- When Tonbo cannot check a key for a moment, the request answers `503` with `Retry-After`; send it again after that long. A token needs no such check.

## The token exchange

Exchange the key when the credential your program sends should expire on its own, such as one a host keeps in a file, or when the key acts as an Agent. Send the key as a bearer token to `POST https://tonbo.dev/api/iam/token`, with the Streams audience, the name of the API the token is for (see [below](#audience)):

```shell
curl -sS -X POST "https://tonbo.dev/api/iam/token" \
  -H "Authorization: Bearer $TONBO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"audience":"https://streams.tonbo.dev"}'
```

The key may instead be sent as `api_key` in the JSON body. A successful answer is JSON:

| Field | Meaning |
| --- | --- |
| `access_token` | The token. Send it to the origin of your database or stream URL as `Authorization: Bearer`. |
| `token_type` | `Bearer`. |
| `expires_in` | Seconds until it expires: 600, or less when the key itself expires sooner. |
| `expires_at` | The same moment as an ISO 8601 timestamp. |
| `audience` | `https://streams.tonbo.dev`, whichever origin serves your database. |
| `client_id` | The key’s id. |
| `sub` | Whom the token acts as: your user id for a key that acts as you, or `agent.` and the Agent’s id for a key that acts as an Agent. |
| `account_id` | The key’s account, the one account the token reaches. Its id is the segment before the name in each of the account’s database URLs. |

The `audience` is required. Without it the exchange returns a token for another Tonbo API, which Streams refuses. An unknown, revoked or expired key answers `401` with `{"error":"Invalid API key."}`.

The exchange counts 600 tokens a minute for each key, exchanges and tokens for a web page together, and 3000 requests a minute from each address. Past either, it answers `429` with the code `rate_limited` and `Retry-After: 60`. A program that sends its key directly makes no exchange at all.

The audience is `https://streams.tonbo.dev`, the name of the Tonbo Streams API, and not the origin of your database URL. A token for that audience works at the database origin of every region: `https://db.tonbo.dev`, `https://streams.ap-southeast-1.tonbo.dev`. In `us-east-1`, Streams also answers at `https://streams.tonbo.dev` beside `https://db.tonbo.dev`. Both names serve the same data and accept the same tokens, and the audience stays `https://streams.tonbo.dev` on both, so a host that moves a database from one name to the other keeps its refresher and its key.

## Tokens for a web page

Your server can mint a token narrower and shorter-lived than its own key or token, to hand to one client such as a web page, which then calls the stream directly (see [A token for a web page](https://tonbo.dev/docs/streams/access.md#browser)). Send JSON to the same `POST https://tonbo.dev/api/iam/token`, with no `Authorization` header:

```shell
PAGE_TOKEN=$(jq -n --arg key "$TONBO_API_KEY" '{
    grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
    subject_token: $key,
    subject_token_type: "urn:tonbo:params:oauth:token-type:api_key",
    streams: ["events"],
    read_only: true,
    expires_in: 300
  }' | curl -sS -X POST "https://tonbo.dev/api/iam/token" \
  -H "Content-Type: application/json" -d @- | jq -r .access_token)
```

| Field | Meaning |
| --- | --- |
| `grant_type` | `urn:ietf:params:oauth:grant-type:token-exchange`. Required. |
| `subject_token` | What the token is minted from: a key, or a Streams token. Required. |
| `subject_token_type` | `urn:tonbo:params:oauth:token-type:api_key` for a key, which may act as you or as an Agent; `urn:ietf:params:oauth:token-type:access_token` for a Streams token, including one minted this way. Required. |
| `streams`, `stream_prefixes`, `databases`, `database_prefixes` | What the token reaches: up to 20 names or prefixes in each, by the names `tonbo stream list` and `tonbo database list` show, and at least one in all. Each must lie within what the subject reaches. A name need not exist yet. |
| `read_only` | `true` when left out. `false` gives the subject’s writes on what the token reaches, and only a writable subject may ask for it. |
| `expires_in` | Seconds, from 60 to 600; 600 when left out. The token never outlives its subject. |
| `audience`, `requested_token_type` | Optional; when sent, `https://streams.tonbo.dev` and `urn:ietf:params:oauth:token-type:access_token`. |

Any other field is refused. The answer has the fields of an exchange’s, with the subject’s `sub` and `client_id`, and `scope` listing what the token reaches. The token is never wider than its subject and lives at most 600 seconds; revoking the key it came from stops it with the key’s other tokens. A refusal is `{"error": …, "code": …}`:

| Code | Status | When |
| --- | --- | --- |
| `invalid_request` | `400` | A field the table above does not name, a required field missing, or a field of the wrong type. |
| `unsupported_token_type` | `400` | `subject_token_type` or `requested_token_type` is not one of the values above. |
| `invalid_target` | `400` | `audience` is not the Streams audience. |
| `invalid_narrowing` | `400` | A name or prefix breaks its naming rule, a list is too long, or the request names nothing. |
| `invalid_lifetime` | `400` | `expires_in` is not a whole number in range. |
| `invalid_subject_token` | `401` | The key is unknown, revoked or expired, or the token does not verify. |
| `subject_not_down_scopable` | `403` | The subject is not a key or a member’s Streams token, or is narrowed in a way that cannot be narrowed by name. |
| `narrowing_exceeds_subject` | `403` | The request names a stream, database or prefix the subject does not reach. Nothing is trimmed to fit. |
| `read_only_subject` | `403` | `read_only` is `false` and the subject is read-only. |
| `subject_token_expiring` | `400` | The subject has less than a minute left: exchange the key again. |

A token minted from a key reaches the named streams only in the key’s account, the `account_id` of the answer. Each request counts toward the exchange’s limits (see [above](#limits)).

## What a key can reach

A key belongs to one account and acts as one of its members, with that member’s current role: you, or an Agent of the account, an identity with its own keys and the role you give it, created on **Members**. Its tokens reach databases and streams as follows:

- A key that is not narrowed reads and writes every database and stream its member can reach in the key’s account. A key reaches only the account you created it in, even when you also belong to others: a database or stream of another account answers as if it did not exist, so a program that works in two accounts holds a key of each.
- A key narrowed to databases, which you choose under **Databases** and **Name prefixes** when you create it (`--database` and `--database-prefix` in the CLI), reads and writes only the databases it names and those whose name starts with one of its prefixes, for its whole life.
- A key narrowed to streams, which you choose under **Streams** and **Stream prefixes** (`--stream` and `--stream-prefix`), reaches only the streams it names and those whose name starts with one of its prefixes, by the names `tonbo stream list` shows, for its whole life. A key narrowed to both databases and streams reaches both; a key narrowed to one reaches nothing of the other.
- A narrowed key neither manages nor calls Machines: a Machine’s private port takes a key that is not narrowed (see [Private ports](https://tonbo.dev/docs/machines/requests.md#private-ports)).
- A narrowed key can also be read-only (`--read-only`): it then reads and tails, and never writes or appends. Through the Management API a narrowed key only reads: the account, and the databases and streams it reaches, which is how an application opens a database by name with it. Apart from revoking itself, it never creates, changes or deletes anything.
- A key that acts as you and is not narrowed can also call the Tonbo Management API at `https://api.tonbo.dev` with whatever your role allows in the account, such as creating and deleting databases.
- Revoking a key stops new exchanges at once, and the key itself, sent directly, within 60 seconds. Streams in every region learns of the revocation shortly after and refuses the tokens already exchanged from it, which in any case stop working when they expire: up to 10 minutes, plus the minute Streams allows for clock skew.

Give each application a key of its own, narrowed to its databases or streams, so a leaked key exposes nothing else. For example, a key that only reads one stream:

```shell
tonbo api-key create events-reader --read-only --stream events --expires-in 15_days
```

To keep it independent of any person, create an Agent on **Members** and a key that acts as it. Keep the key in a secret store and give it an expiry. To rotate a key, create the next one, give it to the application (or the refresher) and restart it, then revoke the old key.

## Why the refresher is a separate process

`open()` and `resolveDatabase()` from `@tonbo/sqlite-ursula/tonbo` need no refresher. The refresher is only for hosts that do not run the package and for anyone who wants the token in a file (`URSULA_VFS_TOKEN_FILE`).

[`node:sqlite`](https://nodejs.org/docs/latest-v22.x/api/sqlite.html) is synchronous. While a commit waits for Tonbo, the thread that would call `setToken` is blocked, so a token that expires during a retried commit cannot be replaced from inside the application. The extension instead reads the file named by `URSULA_VFS_TOKEN_FILE` again whenever it changes and after every `401`, including between the retries of one commit. A refresher in another process can therefore replace the token at any moment.

## The refresher

Save this as `refresh-token.mjs`. It needs Node.js 22 or later and has no dependencies.

**refresh-token.mjs**

```js
#!/usr/bin/env node
// Keeps the file named by URSULA_VFS_TOKEN_FILE holding a fresh Tonbo Streams
// access token for @tonbo/sqlite-ursula. It exchanges the Tonbo API key in
// TONBO_API_KEY for a token with the Streams audience (600 s in production)
// and replaces the file atomically: a temporary file next to it, then a rename.
//
// By default it keeps running and refreshes after about a third of each
// token's lifetime, with jitter. A failed refresh is logged and retried, and
// once the token in the file has expired the process exits 1, so a supervisor
// restarts it and alerts. With --once it refreshes once, quietly, and exits 0,
// or 1 on failure (for cron or a systemd timer).
import { rename, rm, writeFile } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";

const AUDIENCE = "https://streams.tonbo.dev";
const tokenUrl = process.env.TONBO_TOKEN_URL || "https://tonbo.dev/api/iam/token";
const url = URL.canParse(tokenUrl) ? new URL(tokenUrl) : null;
const LOOPBACK = ["127.0.0.1", "localhost", "[::1]"];
const { TONBO_API_KEY: key, URSULA_VFS_TOKEN_FILE: file } = process.env;
const once = process.argv.includes("--once");
const log = (level, event, detail = "") =>
  console.error(`streams-token-refresher level=${level} event=${event}${detail}`);

/** Exchanges the key for a Streams token, writes it, and returns its lifetime in ms. */
async function refresh() {
  const response = await fetch(url, {
    method: "POST",
    headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
    body: JSON.stringify({ audience: AUDIENCE }),
    redirect: "error",
    signal: AbortSignal.timeout(15_000),
  });
  const body = await response.json().catch(() => null);
  if (!response.ok || typeof body?.access_token !== "string" || !(body.expires_in > 0)) {
    throw new Error(`token endpoint answered ${response.status}: ${body?.error ?? "no token"}`);
  }
  const temp = `${file}.${process.pid}.tmp`;
  // Created afresh ("wx"): never written through a link, or into a file with a
  // wider mode, that a crashed run or anyone else left at this name.
  await rm(temp, { force: true });
  await writeFile(temp, `${body.access_token}\n`, { flag: "wx", mode: 0o600 });
  await rename(temp, file);
  return body.expires_in * 1000;
}

async function run() {
  let lifetime = 0;
  let expiresAt = 0;
  for (;;) {
    try {
      lifetime = await refresh();
      expiresAt = Date.now() + lifetime;
      if (once) return 0;
      log("info", "refreshed", ` expires_in_s=${lifetime / 1000}`);
      await sleep((lifetime / 3) * (0.8 + 0.4 * Math.random()));
    } catch (error) {
      const cause = error.cause?.code ?? error.cause?.message;
      const reason = cause ? `${error.message} (${cause})` : error.message;
      log("error", "refresh_failed", ` error=${JSON.stringify(reason)}`);
      if (once || Date.now() >= expiresAt) return 1;
      await sleep(lifetime / 20);
    }
  }
}

if (!key?.startsWith("tbo_") || !file) {
  log("error", "misconfigured", ' detail="set TONBO_API_KEY (tbo_...) and URSULA_VFS_TOKEN_FILE"');
  process.exitCode = 2;
} else if (
  url?.protocol !== "https:" &&
  !(url?.protocol === "http:" && LOOPBACK.includes(url.hostname))
) {
  log("error", "misconfigured", ' detail="TONBO_TOKEN_URL must be an https URL"');
  process.exitCode = 2;
} else {
  process.exitCode = await run();
}
```

| Variable | Meaning |
| --- | --- |
| `TONBO_API_KEY` | The `tbo_` key to exchange. Required. |
| `URSULA_VFS_TOKEN_FILE` | The file to keep fresh. Required. Give the application the same value. |

Run it as the application’s user, because it writes the file with mode `0600`. Each refresh writes a temporary file next to it and renames it over the file, so the application never reads a partial token. The application must have `URSULA_VFS_TOKEN_FILE` in its environment when it starts, because the extension reads the variable once, and the file must hold a token before the first open.

### Timing and failures

- It refreshes after a third of each token’s lifetime, with 20% jitter: every 160 to 240 seconds for a 600 second token. The token in the file still has at least 360 seconds left when a refresh starts, which covers a short outage of the exchange.
- A failed refresh is logged as `streams-token-refresher level=error event=refresh_failed` and retried every 30 seconds (a twentieth of the lifetime).
- Once the token in the file has expired, it exits with status 1, and it exits 1 at once if the first exchange fails. Run it under a supervisor that restarts it, and alert on the restarts.
- With `--once` it refreshes once and exits: 0 without output on success, 1 with the error on failure. Use it from cron or a timer, and before the application starts.

## Running the refresher

### systemd

A service that keeps `/run/tonbo/streams-token` fresh. Its comments list the lines the application’s unit needs to wait for the first token.

**/etc/systemd/system/streams-token-refresher.service**

```ini
# /etc/systemd/system/streams-token-refresher.service
#
# Keeps /run/tonbo/streams-token fresh. Put TONBO_API_KEY=tbo_... in
# /etc/tonbo/streams-token-refresher.env (mode 0600). In the application's unit add:
#
#   Wants=streams-token-refresher.service
#   After=streams-token-refresher.service
#   Environment=URSULA_VFS_TOKEN_FILE=/run/tonbo/streams-token
#   ExecStartPre=/bin/sh -c 'until [ -s /run/tonbo/streams-token ]; do sleep 1; done'
#
# Alert on this unit's restarts (NRestarts) or on level=error lines in its journal.
[Unit]
Description=Tonbo Streams token refresher
Wants=network-online.target
After=network-online.target

[Service]
# The application's user: the token file is readable by its owner only.
User=app
EnvironmentFile=/etc/tonbo/streams-token-refresher.env
Environment=URSULA_VFS_TOKEN_FILE=/run/tonbo/streams-token
RuntimeDirectory=tonbo
RuntimeDirectoryPreserve=yes
ExecStart=/usr/bin/node /opt/streams-token-refresher/refresh-token.mjs
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
```

### cron

**/etc/cron.d/streams-token-refresher**

```text
# /etc/cron.d/streams-token-refresher
#
# Refreshes every 3 minutes, a third of the 600 s token lifetime, so two more
# runs can fail before the token in the file expires. The env file holds
# TONBO_API_KEY=tbo_... and URSULA_VFS_TOKEN_FILE=/var/lib/tonbo/streams-token.
# The command runs as app and reads the env file itself, so app must own it
# (mode 0600), and /var/lib/tonbo must exist and be writable by app. Cron's
# PATH is /usr/bin:/bin, so node is named by its full path. A successful run
# prints nothing, and cron mails a failed run's output to MAILTO. Run the same
# command once before the application starts, so the file exists.
MAILTO=ops@example.com
*/3 * * * * app sh -c 'set -a && . /etc/tonbo/streams-token-refresher.env && exec /usr/bin/node /opt/streams-token-refresher/refresh-token.mjs --once'
```

### Kubernetes

The refresher as a native sidecar (Kubernetes 1.29 or later): it starts before the application, which starts once the first token is written. The Deployment uses the `Recreate` strategy because a database has one writer at a time. A StatefulSet works too.

**kubernetes.yaml**

```yaml
# An application using @tonbo/sqlite-ursula, with the refresher as a native
# sidecar (Kubernetes 1.29 or later). Create its inputs first:
#
#   kubectl create secret generic tonbo-streams --from-literal=api-key=tbo_...
#   kubectl create configmap streams-token-refresher --from-file=refresh-token.mjs
#
# The sidecar starts before the application, which starts once the first token
# is written. A sidecar that cannot refresh exits 1 when its token expires and
# is restarted; alert on its restarts.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: sqlite-app
spec:
  replicas: 1
  # One writer per database: the old pod stops before the new one attaches.
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: sqlite-app
  template:
    metadata:
      labels:
        app: sqlite-app
    spec:
      # One user for both containers: the token file is readable by its owner only.
      securityContext:
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
      initContainers:
        - name: streams-token-refresher
          image: node:22-alpine
          restartPolicy: Always
          command: ["node", "/opt/streams-token-refresher/refresh-token.mjs"]
          env:
            - name: TONBO_API_KEY
              valueFrom:
                secretKeyRef:
                  name: tonbo-streams
                  key: api-key
            - name: URSULA_VFS_TOKEN_FILE
              value: /run/tonbo/streams-token
          startupProbe:
            exec:
              command: ["test", "-s", "/run/tonbo/streams-token"]
            periodSeconds: 1
            failureThreshold: 60
          volumeMounts:
            - name: token
              mountPath: /run/tonbo
            - name: refresher
              mountPath: /opt/streams-token-refresher
              readOnly: true
      containers:
        - name: app
          image: registry.example.com/sqlite-app:1.0.0
          env:
            - name: URSULA_VFS_TOKEN_FILE
              value: /run/tonbo/streams-token
          volumeMounts:
            - name: token
              mountPath: /run/tonbo
              readOnly: true
            - name: data
              mountPath: /var/lib/sqlite-app
      volumes:
        - name: token
          emptyDir:
            medium: Memory
            sizeLimit: 1Mi
        - name: refresher
          configMap:
            name: streams-token-refresher
        # The database files. A new pod rebuilds them from the stream.
        - name: data
          emptyDir: {}
```
