Pre-release. v0.1 is not out yet, so there is nothing to install and no public source to clone — the quickstart builds from a checkout.
@onbe/server
Section 9 of the contract, implemented once: fetch and cache the control plane’s keys, validate the token, enforce the route’s scope, introspect when the token or the route says to, log the human and the agent on every request, and refuse a chain deeper than this server allows.
The package is @onbe/server, published from a release tag. Nothing outside the platform:
WebCrypto and fetch, Node 20 or later, ESM only. Express and Fastify are not dependencies; the
adapters are typed to the part of a request they touch.
Protect a tool server is the narrative version.
OnbeToolServer
Section titled “OnbeToolServer”const onbe = new OnbeToolServer({ issuer: 'https://onbe.internal.example.com', audience: 'https://jira.internal',});| Option | Type | Default | What it is |
|---|---|---|---|
issuer |
string |
— | The control plane’s issuer URL |
audience |
string |
— | What this server is: the aud a token must carry |
requireActor |
boolean |
true |
Only agents acting for a human |
maxDelegationDepth |
number |
1 |
Longest act chain accepted. Raise it deliberately |
clockSkewSeconds |
number |
60 |
Skew tolerated on exp, nbf and iat |
timeoutMs |
number |
10000 |
Ceiling on a call to the control plane |
realm |
string |
the audience | Named in WWW-Authenticate |
log |
(event: AccessEvent) => void |
one JSON line on stdout | Where every request is recorded |
fetch, now |
— | the globals | For tests |
| Method | Returns | What it does |
|---|---|---|
verifyToken(token) |
Promise<TaskToken> |
Signature, iss, aud, exp, and the claim shape |
authenticate(authorization) |
Promise<TaskToken> |
The same, from an Authorization header |
authorize(claims, policy, options?) |
Promise<OnbeAuthError | undefined> |
The route’s rules. undefined means allowed. Does not log |
guard(authorization, policy, route) |
Promise<TaskToken> |
Both, and logs the decision either way. Throws the refusal |
log(route, claims, denial?) |
void |
One AccessEvent, when you did the checks yourself |
refusal(error) |
Refusal |
Status, headers and body for anything thrown here |
guard is the one to reach for. The others exist because a transport that is not HTTP still has
to do the same things in the same order.
RoutePolicy
Section titled “RoutePolicy”app.post( '/issues/:id/comments', onbeExpress(onbe, { scope: 'jira:comment', highRisk: true }), handler,);| Field | Type | What it does |
|---|---|---|
scope |
string | string[] |
Every scope listed must be present. Omitted means any verified token |
highRisk |
boolean |
Also ask the control plane on every request whether the token is still active, instead of trusting the local check until it expires |
requireActor |
boolean |
Overrides the server’s default for this route |
maxDelegationDepth |
number |
Overrides the server’s default for this route |
An empty scope — an empty string as much as an empty array — is refused rather than
treated as “no scope needed”: a policy whose values went missing on the way in is a
configuration accident, and serving it would be serving whatever was left.
highRisk is not the only way introspection happens. A token whose audience the agent’s
registration lists in high_risk_audiences carries introspect_required, and this server
honours it whether or not the route was marked — so the operator’s decision arrives without
anybody editing this file. AuthorizeOptions.alreadyIntrospected exists for a caller that
guards at two levels and does not want to pay for the round trip twice.
TaskToken
Section titled “TaskToken”What a verified token looks like on this side:
interface TaskToken { sub: string; // the human, never an agent act?: Actor; // the agent, when one is acting scopes: string[]; audience: string; jti: string; taskId?: string; exp: number; // seconds since the epoch claims: Record<string, unknown>; // everything else, introspect_required included token: string; // a credential: never log it}Actor is { sub, depth, instance?, act? }: the agent, its depth, the copy of it that asserted
instance if any, and the actor it acts for when the chain is deeper than one. A chain whose
depths do not descend by one is malformed_token.
claimsOf(request) pulls it back out of an Express request after the middleware ran.
Refusals
Section titled “Refusals”Every refusal is an OnbeAuthError with a status, a stable machine-readable reason, and a
message that names the reason and never the token. refusal(error) turns one into the status,
WWW-Authenticate header and JSON body to send.
| Reason | Status | What happened |
|---|---|---|
missing_token |
401 |
No bearer token |
malformed_token |
401 |
Not a JWT, or not one with the claims a task token has |
unsupported_algorithm |
401 |
Signed with something this server does not accept |
unknown_key |
401 |
No such kid in the control plane’s JWKS |
invalid_signature |
401 |
It did not verify |
wrong_type |
401 |
Not a task token |
wrong_issuer |
401 |
From a different control plane |
wrong_audience |
401 |
For a different tool server |
expired, not_yet_valid |
401 |
Outside its lifetime, after clock skew |
no_subject |
401 |
No sub |
subject_is_agent |
401 |
An agent in sub. Delegation, not impersonation |
no_actor |
401 |
No act, where the route requires one |
delegation_too_deep |
403 |
A chain longer than this server accepts |
unknown_route |
403 |
A route with no policy: nobody may call it |
insufficient_scope |
403 |
The token lacks a scope the route requires |
not_active |
401 |
Introspection said the token is revoked or its task is over |
introspection_unavailable |
503 |
The control plane could not be asked |
keys_unavailable |
503 |
Its keys could not be fetched |
The two 503s are the ones worth noticing: they mean this server could not reach the control
plane, not that the caller did anything wrong. It fails closed — a token that could not be
checked is not served — and says so with a status that tells the caller to try again.
not_active means the control plane did not say active: true for this very token — a sub or
jti that does not match counts as not active — and carries the revocation_reason if one was
given. A 503 carries no WWW-Authenticate; a 401/403 does, with error_description made
safe for a header.
The log line
Section titled “The log line”One AccessEvent per request, allowed or refused, as a JSON line on stdout by default:
{ "event": "request", "at": "2026-09-12T14:31:05.412Z", "route": "POST /issues/:id/comments", "decision": "deny", "reason": "insufficient_scope", "sub": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "act": "agent:jira-triage", "depth": 1, "task_id": "task_01HQZX9K4M", "jti": "tok_01HQZX9K5P"}sub and act on every line is step 5 of the contract and the step people skip. No token, no
query string, no key ever appears in one. Pass log to send them wherever your other logs go.
Adapters
Section titled “Adapters”onbeExpress(onbe, policy) is Express middleware; onbeFastify(onbe, policy) is a Fastify
onRequest hook, and onbeFastifyPlugin registers it for a whole scope. Both are the same class
with the plumbing of one framework around it, and both put the verified claims on the request.
onbeFastifyPlugin(onbe, { unguarded }) takes each route’s policy from its config.onbe and
refuses a route with none (unknown_route). unguarded names routes by /healthz or
GET /healthz; a route that has a policy is guarded whatever unguarded says.
verifyTaskToken(token, options) is step 2 on its own, and JwksCache is step 1 on its own, for
a caller doing the rest itself; OnbeToolServer builds its own cache and does not take one. A
cache serves a fetched set for ten minutes, fetches again when a token names a kid it does not
know — at most every thirty seconds, and a request that arrives during that fetch waits for it
rather than being refused — and keeps serving the last good set if a fetch fails.
© 2026 Onbe