Capability tokens
Sometimes something needs to act for a user without being that user: an AI agent placing one order, a background worker finishing a checkout, another service reading one report. Giving it the user's session gives it everything the user can do, for as long as the session lasts.
A capability token is the narrow alternative. It names a viewer, the operations its holder may call, and when it expires. It is signed, so verifying one needs no storage and no round trip, and it can be narrowed further and passed on.
This is the extension cap, defined in spec 06 §6. Minting and verifying tokens is TypeScript-only today. The JVM runtime enforces the operation list all the same: a viewer your hook builds with caps.ops, from a token a TypeScript service signed for instance, can call only the operations it names.
Mint one
import { Capabilities } from "@rayfold/server";
const caps = new Capabilities({ secret: process.env.CAP_SECRET!, maxTtlMs: 3_600_000 });
const token = caps.mint(
{ id: "u1", role: "customer" },
{ ops: ["book", "buy"], ttlMs: 60_000 },
);The secret signs tokens, so it stays on the server; anything derived from it must not leave. maxTtlMs is the longest life any token may have — one hour by default — and it bounds every token, minted or narrowed.
A token looks like this, and nothing in it is secret from its holder:
rfcap1.<payload as base64url JSON>.<HMAC-SHA256, base64url>The payload is { viewer, ops, exp, jti, iss?, caps? }. A token is a reference, not a password: the signature is what stops it being edited, not obscurity.
Use one as the viewer
Turn a token into the viewer for a request, where you would otherwise read a session:
const handler = createHttpHandler(server, {
viewer: (req) => {
const auth = req.headers.authorization ?? "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
return token.startsWith("rfcap1.") ? caps.viewerOf(token) : sessionViewer(req);
},
});viewerOf verifies the signature and the expiry, and answers the viewer the token speaks for with the token's own facts under caps. Two things then happen on every request:
- The operation list is enforced by the runtime. A holder calling anything outside
opsis refused withpermission_denied, before the operation runs and whatever the schema's policies say. - The schema's policies still run, unchanged, on the viewer the token names. A token is a narrowing, never a widening: it cannot grant what the viewer could not already do.
Policies can read the token's facts as viewer.caps.* — viewer.caps.jti, viewer.caps.exp, viewer.caps.iss, and anything you passed as caps when minting:
command refund(orderId: ID): Order @allow(write: viewer.caps.iss == "support-console")Narrow and pass on
A holder that wants a narrower token asks the server that minted it, which derives one without the secret ever leaving it:
const narrower = caps.attenuate(token, { ops: ["book"], ttlMs: 10_000 });Attenuation only ever takes away. The new token's operations are a subset of the old one's, its life is no longer, and a fact the parent did not carry cannot appear — a derived token may drop caps entries but never add or change one. Removal is the one narrowing a server can verify without knowing what a fact means.
Keeping them safe
- Keep lives short. Minutes, not days. A token cannot be revoked — expiry is the whole revocation story — so its life is the blast radius.
jtinames a token if you want to keep your own deny list. A WebSocket opened with a token does not outlive it: atexpits open operations end withunauthenticatedand the socket closes with code1008. - Give the smallest
opsthat works. The list is what the runtime enforces; everything else is your schema's policies doing their usual job. - Treat it as a credential in transit. It is not secret from its holder, but anyone who has it is its holder.
- Do not put anything in
capsyou would not show the holder. The payload is readable base64url.
cap is not negotiated through the manifest, and a server does not list it in extensions — there is nothing for a client to negotiate, since a holder that has a token simply presents it.
Next
- Who can do what — the policies that run on the viewer a token names.
- MCP for AI agents — the most common reason to want one.