Skip to content
Lakefront
Esc
↑↓navigate↵open⌘Jpreview
On this page

Turn on auth

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.

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.

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.

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:

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

Last updated on September 10, 2026

Was this page helpful?