Framework Bindings
requireAgentAccount for Next.js, Express, and Hono — real working examples, and the one way each differs.
Each binding exports one function, requireAgentAccount, and shares a single implementation: the token is read, verified, resolved, and mapped to a status in one place that cannot drift between frameworks. You supply audience and a resolver, and get back your own user id or a finished response.
All three accept the same options minus the ones that would make the check attacker-controlled:
| Not accepted | Why |
|---|---|
token | The token can only come from the request's own Authorization header. There is no option through which a token could arrive from a query string or a cookie. |
jwks, jwksUrl, fetchJwks | A key source the caller supplies is not a key check — it is the other end of the handshake, where the caller supplies both the keys and the signature. Call verifyAgentAccount directly if you need one. |
Next.js
requireAgentAccount(request, options) where request is what a route handler receives. NextRequest satisfies it unchanged.
import { requireAgentAccount } from "@agentonboard/sdk/next";
const accounts = new Map([["ada@example.com", "u_1"]]);
export async function POST(request: Request) {
const session = await requireAgentAccount(request, {
audience: "notes.com",
// `email` is the claim verbatim. Normalize here, once.
resolveAccount: async ({ email }) => accounts.get(email.toLowerCase()) ?? null,
});
// A finished response to return as-is: 401, 403, or 500.
if (!session.ok) return session.response;
return Response.json({ created_by: session.user });
}Call it in the route handler, not in middleware
Middleware cannot pass a resolved user on to a handler without writing it somewhere a client can influence, and that id is the authorization the handler scopes its query by. A middleware check plus a re-check here is two verifications; a middleware check alone is no verification at all. If you want an early redirect for UX, do it in middleware and verify here.
NextResponse is not required — a route handler may return any Response, and session.response is one.
Express
requireAgentAccount(req, res, options). Three arguments, and this is the one binding that writes to res.
import express from "express";
import { requireAgentAccount } from "@agentonboard/sdk/express";
const accounts = new Map([["ada@example.com", "u_1"]]);
const api = express();
api.post("/notes", async (req, res) => {
const session = await requireAgentAccount(req, res, {
audience: "notes.com",
resolveAccount: async ({ email }) => accounts.get(email.toLowerCase()) ?? null,
});
// The status, headers, and body are already on the wire. Just return.
if (!session.ok) return;
res.json({ created_by: session.user });
});Why Express takes res
An Express handler's return value is discarded. There is no way to hand a Response back, and a partner who wrote res.json(await session.response.json()) would have to remember the status and could easily answer 200 for a failed check. Here the rejection is relayed whole — status, every header, then the body — before the promise resolves. Nothing is written twice, and nothing is written on success.
Hono
requireAgentAccount(c, options). Works on Workers, Node, Deno, and Bun.
import { Hono } from "hono";
import { requireAgentAccount } from "@agentonboard/sdk/hono";
const accounts = new Map([["ada@example.com", "u_1"]]);
const app = new Hono();
app.post("/notes", async (c) => {
const session = await requireAgentAccount(c, {
audience: "notes.com",
resolveAccount: async ({ email }) => accounts.get(email.toLowerCase()) ?? null,
});
// The failure is a WHATWG Response; Hono sends it as-is.
if (!session.ok) return session.response;
return c.json({ created_by: session.user });
});
export default app;There is no c.set and no c.get
These bindings do not touch the Hono context's variable map. session.user is the only way to get the id out of a handler — c.get("agentUser") is always undefined, and a middleware-style usage that reads it will fail at the first query.
What the bindings do that you should not have to re-derive
- The token is read from
Authorizationand nowhere else. ABearerprefix is stripped case-insensitively, so a token your own middleware already stripped still verifies. audienceandissuerare literals you write in your source. Neither is read off the request. An audience the caller controls is not a check.- Your resolver gets
{ email }and nothing else. Not the request, the headers, the cookies, the IP, the token, or our user id. - Your resolver's result is never cached, and it runs once per request. A stale "this user exists" is how a deleted or banned account keeps working.
- The code → status → body mapping is written once. A missing header, an expired token, a bad signature, the wrong audience, an unknown key, and a dead key endpoint all answer
401.ACCOUNT_REQUIREDanswers403. A resolver that threw answers500.
Custom failure responses
onFailure receives one argument — { code, error } — and its return value is used verbatim: status, headers, and body, nothing merged in with the defaults.
onFailure takes ONE argument and must return a WHATWG Response
(failure) => Response. Returning an Express res instead of a Response crashes the adapter — there is no res.status() on the object it expects to .status() and .headers.forEach().
import { requireAgentAccount } from "@agentonboard/sdk/next";
const session = await requireAgentAccount(request, {
audience: "notes.com",
resolveAccount,
onFailure: ({ code, error }) =>
code === "ACCOUNT_REQUIRED"
? Response.json(
{ problem: "connect_account", error },
{ status: 403, headers: { location: "https://notes.com/connect" } },
)
: Response.json({ code, error }, { status: 401 }),
});The default body is { "code": "...", "error": "..." }. That publishes the specific code to an unauthenticated caller, deliberately — a code an agent cannot branch on is not a code. It is also an oracle: someone probing your endpoint learns which check they failed. If you would rather not publish that, this is the place to change it.
onFailure is called on verification failure only. It is never called on your handler's own exceptions: a verification failure and a handler failure are different events, and collapsing them hides bugs.
When you need a key source the bindings do not take
A pinned JWKS for an air-gapped issuer, a proxy fetchJwks, or a test with no network all require calling the core directly. Everything else in this page still applies — write the three steps yourself:
import { verifyAgentAccount } from "@agentonboard/sdk";
const token = request.headers.get("Authorization") ?? "";
const outcome = await verifyAgentAccount({
token,
audience: "notes.com",
jwks: pinnedKeys, // no URL, no network
resolveAccount: async ({ email }) => accounts.get(email.toLowerCase()) ?? null,
});
if (!outcome.ok) {
return Response.json({ code: outcome.code, error: outcome.error }, { status: statusFor(outcome.code) });
}
return Response.json({ created_by: outcome.user });403 for ACCOUNT_REQUIRED, here too
If you write the status mapping yourself, copy the rule rather than defaulting: 401 for every token-side code, 403 for ACCOUNT_REQUIRED, 500 for CONFIG_ERROR and RESOLVER_ERROR. The error page has the table and the reasoning.