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?
- Caller-side attenuation. Hand a holder a powerful token; they can scope it down further (add Datalog facts/checks) without contacting the issuer. Useful for delegating limited authority to a sub-process or a downstream service.
- Offline verification. Verifiers only need the public key — no JWKS round-trip per request, no cache eviction, no clock-skew worries.
- Datalog. Authorization policies are expressed as logical rules
(
allow if user("alice"), action("read"), resource_owner("alice");), much more expressive than JWT scopes or ad-hoc claims. - Compact. Protobuf-serialised + binary-signed. Smaller than equivalent JWTs.
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?
| Need | Use | Why |
|---|---|---|
| OIDC id_token / access_token for a downstream RP | JWT (RS256) | Standards-compliant, every client library supports it. |
| Service-to-service capability handed to a sub-process | Biscuit | Sub-process can attenuate further without re-issuing. |
| Browser session | Session cookie | Server-side revocation, CSRF protection, no token-in-URL. |
| Offline verification at high QPS | Biscuit | No JWKS fetch per request; smaller wire size than JWT. |
| Datalog-expressible policy ("can read if owner OR shared with group ∋ user") | Biscuit | Native Datalog; JWT would push policy back to the verifier. |
| Cross-org federation with arbitrary OIDC consumers | JWT | Universal interop. |
Mixing is fine: an OIDC-minted JWT can carry a biscuit in a custom claim for capability-based authorization downstream.
References
- biscuitsec.org — official Biscuit spec + libraries
- biscuit-auth on docs.rs — the Rust crate assay vendors
- Auth & IdP overview — the rest of the v0.2.0 surface
- Compare vs Ory — Hydra/Kratos/Keto have no biscuit equivalent