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

Capability tokens

Lakefront's auth model. Macaroon-style tokens that any holder can attenuate offline, with no server-side revocation list.

A capability token is not a reference to a permission stored somewhere. It is the authorization. Everything a request is allowed to do is encoded in the token itself: which boards, which verbs, until when, and optionally on whose behalf. Verifying one is pure computation over the token’s own bytes; nothing about auth touches a database on the request path.

Macaroon-style chaining

A token is minted from a secret and a set of claims, using HMAC-SHA256 chaining in the pattern macaroons popularized:

sig_0     = HMAC(secret, "lf1." + kid + "." + baseSegment)
sig_{n+1} = HMAC(sig_n, caveatSegment)

The token carries the base claims, zero or more caveat segments, and only the final signature, never the intermediate ones. Minting a root token needs the secret:

import { mintToken } from "lakefront/contract";

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

Attenuating one (deriving a strictly narrower token) needs no secret at all, because the current signature on a token is exactly the chaining key the construction calls for next:

import { attenuate } from "lakefront/contract";

const scoped = attenuate(token, {
  boards: ["acme_roadmap"],
  verbs: ["read"],
  ttlSeconds: 300,
});

Any holder of a token can call attenuate on it, locally, with no server round-trip. This is the property that makes tokens practical to hand out through a chain of services: a backend holding a broad token can mint a narrow, short-lived one for a single frontend request without asking anything to sign it.

Attenuation can only remove authority

The chain construction makes widening a caveat meaningless rather than merely disallowed. Verification does not read “the last caveat wins.” It computes the intersection of the base claims with every caveat in the chain:

  • boards narrow to whichever pattern is more specific
  • verbs narrow to the set every caveat still lists
  • expiry narrows to the earliest of any caveat’s exp

A caveat that tries to add a board or verb the base token never granted contributes nothing, because the intersection with a grant that was never there is empty. There is no way to end up with more authority than the base token started with, regardless of how many caveats are stacked or what they claim.

An actor claim, once set, behaves slightly differently: it identifies who the request is being made on behalf of, and a caveat may inherit it but never swap it for a different actor. An attempt to do so fails verification outright rather than silently losing the original identity.

Claims

interface Claims {
  boards: readonly string[] | "*";
  verbs: readonly ("read" | "write" | "schema" | "admin")[];
  exp: number; // unix seconds
  actor?: string;
}

boards is either "*" for every board, or a list of patterns; a pattern ending in * is a prefix grant. Since boards are named <tenant>_<name> by convention, acme_* expresses “every one of acme’s boards” entirely inside the token vocabulary, without the engine itself having any concept of a tenant. A token whose boards pins exactly one non-prefix board needs no board header on a request at all. The server infers it from the token.

Verification cost and caching

Verifying a token is one HMAC-SHA256 chain recomputation from the root secret, measured at roughly 1.3 microseconds (pure computation, no I/O), which matters because the serving path’s overall budget is on the order of 150 microseconds and auth cannot be allowed to put a round-trip on it. A server node additionally keeps an LRU cache of already-verified tokens, keyed until their own expiry, so steady-state verification on a hot path is a map lookup rather than even that HMAC chain.

Key identity and rotation

Each token names the key that signed it (kid) in its own bytes. A node resolves kid to a secret one of two ways: a single static key from configuration ({ secret }, always kid: "env"), or a rotating registry loaded from the catalog Postgres ({ registry: true }), refreshed on a timer so rotation and revocation propagate within one interval without any auth I/O landing on a request.

A registry key is in exactly one of three states:

State Mints? Verifies?
active Yes Yes
retiring No Yes: the rotation half-step that lets outstanding tokens keep working while nothing new is issued under it
revoked No No: the blunt instrument that invalidates every token minted under that key at once, in a single operation

There is no per-token revocation list; a token’s own exp is the mechanism for bounding how long it can be used, and revoking a key is the tool for anything more urgent than waiting out an expiry. The mint/attenuate/registry/rotation recipe itself is on the auth guide.

Trust model

Verification with a symmetric HMAC secret means anyone holding the secret can both mint and verify, which is fine and deliberate, because a server node already holds the catalog DSN and can read or write anything in the lake directly. Trusting a node with the ability to forge a token buys an attacker nothing they could not already do by reaching the catalog. The trust boundary in this design is the server node, not the token format.

Last updated on September 10, 2026

Was this page helpful?