← Auth & IdP overview

Biscuit capability tokens

Biscuit is a Datalog-based capability-token format with offline verification and caller-side attenuation. assay-engine v0.2.0 ships biscuit-auth as a non-optional dependency — every engine has biscuit support, no feature flag required. This is a real differentiator vs Ory: Hydra and Kratos have nothing equivalent.

Why biscuit?

Root key bootstrap

Every assay-engine boot reads or generates an Ed25519 root key in auth.biscuit_root_keys. The active key (rotated_at IS NULL) signs newly-issued tokens; rotated keys stay around for verification of in-flight tokens until they expire naturally.

-- DDL excerpt
CREATE TABLE auth.biscuit_root_keys (
  kid             TEXT PRIMARY KEY,
  private_pem     BYTEA NOT NULL,
  public_pem      TEXT NOT NULL,
  created_at      DOUBLE PRECISION NOT NULL,
  rotated_at      DOUBLE PRECISION
);

The dashboard's Biscuit keys pane exposes the active kid, the public PEM, and a Rotate action. Clients should cache the public PEM locally and refresh on signature failure.

Issue a token

local auth = require("assay.auth")
local c    = auth.client({ engine_url = "http://localhost:3000" })

-- via HTTP
local token = c.biscuit:issue({
  facts = {
    { 'user("alice")' },
    { 'allowed_action("read")' },
    { 'resource("doc-42")' },
  },
  checks = {
    'check if time($t), $t < 2026-12-31T00:00:00Z',  -- expires Dec 31
  },
})

-- token is a base64url-serialised biscuit; ship it to the holder.

Equivalent HTTP:

curl -X POST https://auth.example.com/auth/biscuit/issue \
  -H "Authorization: Bearer sk_admin_replace_me" \
  -H "Content-Type: application/json" \
  -d '{
    "facts": [["user(\"alice\")"], ["allowed_action(\"read\")"]],
    "checks": ["check if time($t), $t < 2026-12-31T00:00:00Z"]
  }'

Attenuate a token (caller-side)

The token holder can append a block of Datalog before forwarding it. Anything appended is conjunctive — strictly narrowing, never broadening:

local attenuated = c.biscuit:attenuate(token, {
  facts = {
    { 'allowed_action("read")' },                -- still read-only
    { 'allowed_resource("doc-42")' },             -- and only doc-42
  },
  checks = {
    'check if time($t), $t < 2026-04-26T18:00:00Z',  -- and only for the next hour
  },
})
-- forward `attenuated` to a sub-process; even if it leaks, scope is bounded.

Critically: this is purely client-side. No round-trip to the engine. The holder's local biscuit-auth library appends the block and re-signs with their own block-key.

Verify a token

-- Cache the engine's public PEM locally on startup.
local pem = c.biscuit:public_pem()

-- Then verify offline on every request.
local ok, info = c.biscuit:verify(token, pem, {
  facts = {
    { 'time(2026-04-25T12:00:00Z)' },
    { 'operation("read")' },
    { 'resource("doc-42")' },
  },
  policies = { 'allow if user($u), allowed_action("read")' },
})
if not ok then return { status = 401 } end
log.info("biscuit verified for user " .. info.user)

When to use biscuit vs JWT?

NeedUseWhy
OIDC id_token / access_token for a downstream RPJWT (RS256)Standards-compliant, every client library supports it.
Service-to-service capability handed to a sub-processBiscuitSub-process can attenuate further without re-issuing.
Browser sessionSession cookieServer-side revocation, CSRF protection, no token-in-URL.
Offline verification at high QPSBiscuitNo JWKS fetch per request; smaller wire size than JWT.
Datalog-expressible policy ("can read if owner OR shared with group ∋ user")BiscuitNative Datalog; JWT would push policy back to the verifier.
Cross-org federation with arbitrary OIDC consumersJWTUniversal interop.

Mixing is fine: an OIDC-minted JWT can carry a biscuit in a custom claim for capability-based authorization downstream.

References