---
title: Turn on auth
description: How a capability token authorizes a request, how to mint and narrow
  one, and how to manage the keys that sign them.
---

Authorization in Lakefront is a capability token that the caller holds. The token itself carries the entire grant: which boards, which verbs, and until when. Anyone holding a token can derive a narrower one offline, with no secret and no round trip to whoever issued it.

## Start a server and mint a token

A server with no `auth` option lets every request through, which is what keeps local development and the tour untouched. Turning it on is one field, `auth: { secret }`. This example runs the whole path: start a node with a secret, mint a token from that secret, call the node with it, then call again with no token at all.

```ts
import { serve } from "lakefront/server";
import { mintToken } from "lakefront/contract";
import { connect } from "lakefront/client";

const secret = Buffer.from(process.env.LAKEFRONT_AUTH_SECRET!);
const node = await serve({
  postgres: "postgres://lakefront:lakefront@127.0.0.1:5432/lakefront",
  data: "./data",
  auth: { secret },
});

const token = mintToken(secret, "env", {
  boards: ["acme_roadmap"],
  verbs: ["read", "write"],
  exp: Math.floor(Date.now() / 1000) + 3600,
});

const board = connect({ url: node.url, token }).board("acme_roadmap");
await board.insert({ id: "1", title: "Ship the auth guide" }); // ok

const noToken = connect({ url: node.url }).board("acme_roadmap");
await noToken.insert({ id: "2", title: "should fail" });
// Result.err({ _tag: "Unauthenticated", message: "missing bearer token" })
```

## Give a service a token

`mintToken` is the issuer side: whoever holds the secret can call it, and the claims it signs are the entire grant. `boards` is either the literal string `"*"` for every board or a list of board ids, and any entry may end in `*` for a prefix grant: `"acme_*"` covers every board named `acme_...`, which is how a tenant is expressed in the token vocabulary without the engine ever knowing what a tenant is. `verbs` is drawn from `read`, `write`, `schema`, `admin` (see the table below for which are actually checked). `exp` is Unix seconds, and `actor` is optional: it names who the token's writes are attributed to, covered in the closing note.

## Hand a caller a narrower token

`attenuate` derives a strictly weaker token from one already held, entirely offline: no secret, no network round trip. It appends a caveat to the token's HMAC chain, and verification computes the intersection of the base grant with every caveat, so a caveat can only narrow what a token allows, never widen it.

```ts
import { attenuate } from "lakefront/contract";

// From a service token scoped to every board, hand a caller a token that can
// only read one board, for the next five minutes.
const view = attenuate(token, {
  boards: ["acme_roadmap"],
  verbs: ["read"],
  ttlSeconds: 300,
});
```

`ttlSeconds` sets `exp` relative to when `attenuate` runs, and wins over a directly supplied `exp` in the same caveat. A token that pins exactly one non-prefix board, like `view` above, lets the caller omit the board header entirely, since the server derives the board from the claims; a token scoped to `"*"` or a prefix grant still needs `boardId` on `createRpcClient` or the `x-lakefront-board` header directly. The chaining construction and the intersection rule are covered on the [capability tokens page](/concepts/tokens).

## Which verbs exist

| Verb              | Required by                                              |
| ----------------- | -------------------------------------------------------- |
| `read`            | every `query.execute`, `changes.read`, `node.stats` call |
| `write`           | every `mutate.*` call                                    |
| `schema`, `admin` | nothing yet                                              |

Lifecycle and DDL operations (`createBoard`, `addColumn`, `compact`, and similar) are not exposed over HTTP, so there is nothing yet for those two verbs to gate.

## Key management

`ServeOptions.auth` is one of two shapes:

| Shape                | Verifies against                                                                      | Rotation                                             |
| -------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| `{ secret }`         | A single static key, HMAC-SHA256 over that one secret, fixed key id `"env"`           | None: one key, no catalog dependency                 |
| `{ registry: true }` | Keys loaded from the catalog Postgres, refreshed into an in-memory keyring every 30 s | Live: add, retire, or revoke keys by editing a table |

`{ secret }` uses a shared secret from the environment. `{ registry: true }` creates `<namespace>.lakefront_auth_keys` and an initial key named `default` on first boot. `namespace` defaults to `"lakefront"`.

A key's `state` decides what it does: `active` mints and verifies, `retiring` verifies only, and anything else (in practice `revoked`) does neither, and the row drops out of the refresh query. Verification never does I/O: it is a lookup against the keyring loaded at startup and refreshed on the timer, which is why the registry's replication lag doubles as the ceiling on how fast revocation propagates.

The registry table is internal, so rotation is a direct SQL operation against the catalog:

```sql
-- 1. Add a new active key. Existing tokens keep verifying under the old kid.
INSERT INTO lakefront.lakefront_auth_keys (kid, secret, state)
VALUES (
  '2026-09',
  translate(rtrim(encode(gen_random_bytes(32), 'base64'), '='), '+/', '-_'),
  'active'
);

-- 2. Once callers pick up the new kid, stop minting under the old one; it
--    still verifies, so tokens already issued keep working until they expire.
UPDATE lakefront.lakefront_auth_keys SET state = 'retiring' WHERE kid = 'default';

-- 3. Once nothing still holds an old-kid token, kill it outright.
UPDATE lakefront.lakefront_auth_keys SET state = 'revoked' WHERE kid = 'default';
```

Secrets are stored base64url-encoded (the `translate`/`rtrim` wrapping above converts Postgres's standard base64 into that alphabet). Every node picks up the change within one refresh interval (30 s), independently.

## Actor identity and expiry

Every write made under a token records that token's `actor` on the WAL records it produces, which is what makes the change feed and the audit trail show who did what rather than which node happened to serve the request.

A live `/watch` SSE stream carries the same deadline as the token that opened it: the server checks the token's `exp` on every poll cycle and closes the stream once it has passed, rather than serving indefinitely past the credential that authorized it. A client holding a long-lived stream needs to reconnect with a fresh token before that happens.
