An agent served over HTTP needs to know two things about each request: may it
call at all, and who is it. @lousho/build-ai-agent/auth answers both. You
give a route an ordered list of auth entries (jwt(), oidc(), basic(),
apiToken(), anonymous() or your own function); the first entry that
accepts the request returns a principal, and that principal goes into the
run, where your model / instructions / tools functions and memory scopes
can read it.
import { createAgent, createRouteHandler } from '@lousho/build-ai-agent';
import { apiToken, jwt } from '@lousho/build-ai-agent/auth';
import { mockModel } from '@lousho/build-ai-agent/testing';
const agent = createAgent({
provider: mockModel(['Hello!']),
instructions: ({ principal }) => `You are helping ${principal?.id ?? 'a guest'}.`,
});
export const { GET, POST } = createRouteHandler(agent, {
auth: [
jwt({ secret: process.env.JWT_SECRET ?? 'a-development-secret-of-32-bytes!!', issuer: 'https://auth.example.com', audience: 'agent-api' }),
apiToken(process.env.CI_TOKEN ?? 'ci-token', { id: 'ci' }),
],
});Entries run in order, and each one does one of three things:
| An entry ... | Then |
|---|---|
returns a Principal |
the request is accepted; later entries are not asked |
returns null or undefined |
it skips: the next entry is asked |
throws AuthError(401) or AuthError(403) |
the request is refused with that status; later entries are not asked |
When every entry skips, the request gets a 401. An empty list refuses every
request. An entry that throws anything else is a bug: the request gets a 500
with a generic body, and the error is logged with console.error (never sent).
The built-in helpers never throw for a bad credential: a wrong password, an
expired token or a token for another audience all skip, so the next entry gets
its chance (two jwt() entries for two issuers work), and a request nobody
accepts ends in the same 401.
A Principal is:
interface Principal {
id: string; // JWT `sub`, Basic user name, 'api-token', ...
type: 'user' | 'service';
authenticator: string; // 'jwt' | 'oidc' | 'basic' | 'api-token' | 'anonymous' | your name
issuer?: string; // JWT `iss`: the same id from another issuer is another caller
claims?: Readonly<Record<string, unknown>>; // verified claims; never the raw token or a password
}All helpers are created once, at start-up, and check their options then: a
helper that cannot work throws LOUSHO_AUTH_CONFIG_INVALID
instead of refusing every request later.
Accepts Authorization: Bearer <JWT> signed by the key you configure. Give
exactly one key source:
| Option | Key | Algorithms (default: all of the key's family) |
|---|---|---|
secret |
an HMAC secret of at least 32 bytes | HS256, HS384, HS512 |
publicKey |
a PEM -----BEGIN PUBLIC KEY----- or a JWK |
RS256, RS384, RS512 (RSA, 2048 bits or more), ES256 (P-256), ES384 (P-384) |
jwksUrl |
a JWKS endpoint (https, or http on localhost) | RS256, ES256 by default; RSA and EC keys only |
import { jwt } from '@lousho/build-ai-agent/auth';
const fromOurAuthServer = jwt({
jwksUrl: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com',
audience: 'agent-api',
// Map the verified claims to the principal; return null to skip.
principal: (claims) => (typeof claims.sub === 'string' ? { id: claims.sub, type: 'user', authenticator: 'jwt', issuer: claims.iss, claims } : null),
});What is checked:
- The header's
algmust be one ofalgorithms;noneis always refused, and so is a token whose header marks an extensioncrit. - The algorithm must fit the key: a public key is never used as an HMAC secret, whatever the token's header says, and a key set never supplies an HMAC key.
expis required;nbfandiatare honored. All three allowclockToleranceSecof clock skew (default 60, at most 300).issmust equal one ofissuerexactly (no trailing-slash tolerance), when you setissuer.aud(a string or a list) must contain one ofaudience.audienceis required; passallowAnyAudience: trueto skip that check on purpose.- A key set is fetched with a 5-second timeout, cached for 10 minutes, and
refetched at most once every 30 seconds when a token names a
kidit does not know (a failed fetch is retried on the same 30-second bound). Its URL is configuration only: thejku,x5uandjwkheaders of a token are ignored.
The default principal is { id: sub, type: 'user', authenticator: 'jwt', issuer: iss, claims };
a token without sub skips.
A jwt() whose keys come from an OpenID Connect provider. It fetches
<issuer>/.well-known/openid-configuration once (or discoveryUrl), requires
the document's issuer to equal yours exactly, and uses its jwks_uri (https,
or http on localhost / 127.0.0.1 / [::1] — the same rule as jwt()'s
jwksUrl). Algorithms default to RS256 and ES256; HMAC is not allowed.
import { oidc } from '@lousho/build-ai-agent/auth';
const google = oidc({ issuer: 'https://accounts.google.com', audience: process.env.GOOGLE_CLIENT_ID ?? 'client-id' });The principal has authenticator: 'oidc' and the provider's iss as issuer.
HTTP Basic credentials. User names and passwords are NFC-normalized and
compared in constant time, and every configured user is compared on every
request, so an unknown user takes as long as a wrong password. The 401
carries WWW-Authenticate: Basic realm="<realm>", charset="UTF-8" so browsers
show their login prompt.
import { basic } from '@lousho/build-ai-agent/auth';
const operators = basic({ users: { ops: process.env.OPS_PASSWORD ?? 'change-me' }, realm: 'agent' });
// Or check against your own store (compare in constant time there):
const fromDatabase = basic({ users: async (user, password) => user === 'svc' && password === (process.env.SVC_PASSWORD ?? '') });Only use basic() over HTTPS: the password travels with every request.
apiToken(token, { id }) accepts Authorization: Bearer <token> (compared in
constant time) as the service principal { id: id ?? 'api-token', type: 'service', authenticator: 'api-token' }.
It is what a token string has always meant: createRouteHandler({ auth: '<token>' })
and LOUSHO_API_TOKEN are apiToken().
anonymous() accepts everyone as { id: 'anonymous', type: 'user', authenticator: 'anonymous' }:
put it last for routes that serve signed-in and anonymous callers, and read
principal.authenticator in the run.
Any function of the Request is an entry. Declare the challenges it answers a
401 with, or it advertises Bearer:
import { AuthError, type AuthFn } from '@lousho/build-ai-agent/auth';
const tenantKey: AuthFn = async (request) => {
const key = request.headers.get('x-tenant-key');
if (!key) return null; // not ours: ask the next entry
if (key === 'suspended-tenant') throw new AuthError(403); // stop here
return { id: key, type: 'service', authenticator: 'tenant-key' };
};
tenantKey.challenges = [{ scheme: 'Bearer', realm: 'tenants' }];- Nobody accepted the request (or an entry threw
AuthError(401)):401with{ "error": "Unauthorized" }and oneWWW-Authenticateheader per distinct challenge of the list, in order (Bearerforjwt,oidc,apiToken;Basic realm="..."forbasic). - An entry threw
AuthError(403):403with{ "error": "Forbidden" }. - An entry failed in another way:
500with{ "error": "Internal Server Error" }.
The body never says which check failed: an expired token, a bad signature and
a wrong audience all get the same 401. Every auth response carries
Cache-Control: no-store. Tokens and passwords are never logged.
| Helper or server | Node 22+ | Cloudflare Workers, Vercel Edge, Bun, Deno |
|---|---|---|
jwt(), oidc(), basic(), apiToken(), anonymous(), routeAuth() |
yes | yes: Web Crypto and fetch only, no node: import |
createRouteHandler({ auth }) |
yes | yes |
createDeployedServer({ auth }), the node-server and docker targets |
yes | no (Node http) |
An agent directory's auth.ts |
yes (node-server, docker) |
no |
The cloudflare-worker build target |
LOUSHO_API_TOKEN only; no auth list yet |
oidc() and jwt({ jwksUrl }) need outbound fetch to the key set, which
every listed runtime has. In a hand-written Worker, call createRouteHandler
or routeAuth() yourself with any helper.
auth takes the list, one entry, a token string, or (as before) a function
that returns a boolean. true accepts with the principal
{ id: 'anonymous', type: 'user', authenticator: 'custom' }, false is a
401. Without auth the route is open, and in production
(NODE_ENV=production) it logs one warning. GET <basePath>/health is never
behind auth. The accepted principal reaches every route: POST <basePath>,
/chat, approvals and the useChat endpoint. See Next.js.
createDeployedServer(agent, { auth }) takes the same list (or one entry).
When LOUSHO_API_TOKEN is set, apiToken(LOUSHO_API_TOKEN) is appended after
your entries, so an operator's token keeps working. Channels under
/channels/<name> are not behind the list: they verify their own requests.
In an agent directory, put the list in auth.ts (or
auth.js), next to agent.ts:
// my-agent/auth.ts
import { apiToken, oidc } from '@lousho/build-ai-agent/auth';
export default [
oidc({ issuer: 'https://login.example.com', audience: 'agent-api' }),
apiToken(process.env.CI_TOKEN!, { id: 'ci' }),
];lousho build --target=node-server (or docker) bundles it, and the built
server uses it (resolveAgentDir() returns it as auth; the manifest says
auth: true). With an auth.ts, a token baked in with
adapter.scaffold(..., { auth: { token } }) is not used; LOUSHO_API_TOKEN
still is. The cloudflare-worker target serves spec files only and keeps the
LOUSHO_API_TOKEN token; see Deployment.
The principal is on the run context next to sessionId and metadata. The
vendor/ model prefix chooses the provider; with OpenRouter use
openrouter/<vendor>/<model> (e.g. openrouter/openai/gpt-4o-mini).
import { createAgent, defineMemory, inMemoryMemory, type Principal } from '@lousho/build-ai-agent';
import { mockModel } from '@lousho/build-ai-agent/testing';
const notes = defineMemory({
name: 'notes',
// One memory per caller. Key on the issuer too: ids from different issuers can collide.
scope: ({ principal }) => principal && `user:${principal.issuer ?? ''}:${principal.id}`,
provider: inMemoryMemory(),
});
const agent = createAgent({
provider: mockModel(['Hi!']),
model: ({ principal }) => (principal?.claims?.plan === 'pro' ? 'openai/gpt-4o' : 'openai/gpt-4o-mini'),
instructions: ({ principal }) => (principal?.type === 'service' ? 'Answer in JSON.' : 'Be friendly.'),
memory: [notes],
});
// Outside a route, pass the principal yourself:
const caller: Principal = { id: 'u-42', type: 'user', authenticator: 'jwt', issuer: 'https://auth.example.com' };
await agent.send('Hello', { principal: caller });
await agent.session({ id: 'chat-1' }).send('Hello again', { principal: caller });principal is separate from metadata on purpose: metadata is whatever the
caller sent, principal is what auth verified. A run that is resumed from its
checkpoint or after an approval keeps the principal it started with. The Slack,
Discord, Teams, GitHub and Telegram channels set the sender as the principal
(authenticator: 'slack', 'discord', ...). Tools, approval policies,
permission rules and sub-agents see it too: see
Principals in tools and approvals.
- Route auth does not check who owns a session. Any caller who passes auth
and knows a session id can read its transcript (
GET /chat/:id), continue it, and decide its pending approvals. Use session ids that cannot be guessed, derive them from the principal on your side, or check ownership in your own route before calling the handler. A caller who decides another caller's approval is recorded as the approver (ctx.approval.by); the run keeps acting for the caller that started it. basic()only over HTTPS; a bearer token is a password too, so terminate TLS in front of any server that is not on localhost.- Keep
issuerandaudienceset forjwt(): a token minted for another of your services is otherwise accepted here.
Everything that decides or acts on the caller's behalf gets the run's principal, frozen (a tool or a policy that assigns to it throws):
| Where | How |
|---|---|
A tool's execute(args, ctx) |
ctx.principal |
A needsApproval function |
ctx.principal (its second argument) |
A permission rule's when(args, ctx) |
ctx.principal |
preToolCall / postToolCall hooks |
ctx.principal |
onPermissionDecision(entry, { principal }) |
the second argument; the entry itself carries none |
A pending approval (agent.approvals.list(), approve, a channel's approvers function) |
request.principal: whose call it is |
In-process sub-agents (task, background tasks) |
the lead's principal, everywhere above |
A run without a principal (no route auth, send() without principal) has
none in any of these places.
import { defineTool } from '@lousho/build-ai-agent';
import { z } from 'zod';
export const myOrders = defineTool({
name: 'my_orders',
description: "Lists the caller's orders",
input: z.object({}),
execute: async (_args, ctx) => {
if (!ctx.principal) return { error: 'Sign in first.' };
// Key on the issuer too: the same id from another issuer is another caller.
return { owner: `${ctx.principal.issuer ?? ''}:${ctx.principal.id}`, orders: [] };
},
});The principal comes only from route auth or a channel's verified sender, never from the request body or the model, and a tool cannot change it.
import { createAgent, defineTool } from '@lousho/build-ai-agent';
import { mockModel } from '@lousho/build-ai-agent/testing';
import { z } from 'zod';
const deploy = defineTool({
name: 'deploy',
description: 'Deploys a service',
input: z.object({ service: z.string() }),
// Ask a human only when the caller has no `admin` claim.
needsApproval: (_args, ctx) => ctx.principal?.claims?.admin !== true,
execute: async ({ service }) => `deployed ${service}`,
});
const agent = createAgent({
provider: mockModel(['Ready.']),
tools: [deploy],
// Service accounts (CI tokens) may not deploy at all.
permissions: [{ tool: 'deploy', when: (_args, ctx) => ctx.principal?.type === 'service', action: 'deny', reason: 'Not for service accounts' }],
});
await agent.send('Deploy the API', { principal: { id: 'ci', type: 'service', authenticator: 'api-token' } });A decision can name who made it. The approved tool sees the decider as
ctx.approval.by, next to ctx.approval.note:
import { createAgent, defineTool, type Principal } from '@lousho/build-ai-agent';
import { mockModel } from '@lousho/build-ai-agent/testing';
import { z } from 'zod';
const sendEmail = defineTool({
name: 'send_email',
description: 'Sends an email',
input: z.object({ to: z.string() }),
needsApproval: true,
execute: async ({ to }, ctx) => `sent to ${to} for ${ctx.principal?.id}, approved by ${ctx.approval?.by?.id}`,
});
const agent = createAgent({
provider: mockModel([{ toolCalls: [{ name: 'send_email', args: { to: 'sam@example.com' } }] }, 'Sent.']),
tools: [sendEmail],
});
const alice: Principal = { id: 'alice', type: 'user', authenticator: 'jwt' };
const lead: Principal = { id: 'lead', type: 'user', authenticator: 'jwt' };
const paused = await agent.send('Email Sam', { principal: alice });
// The tool runs for alice; lead is recorded as the approver.
await agent.approvals.resolve({ id: paused.approvalId!, approved: true }, { principal: lead });resolve(), answer(), streamResolve() and streamAnswer() take
{ principal }. The approvals route (POST /chat/:sessionId/approvals/:id)
passes the caller its auth list accepted; body fields cannot set it.
mountChannels() passes the user who clicked as
{ id, type: 'user', authenticator: <channel name> }, or, for a question
answered with a message, that message's sender. Decisions made by an approve
callback, or posted to /channels/<name>/approvals/:id, have no approver.
The approver never becomes the run's principal: the approved call and the rest of the run act for the caller that paused it. A credential or a grant that belongs to that caller is used only for that caller's calls, whoever approves.
The principal is saved with the run's checkpoints and approval snapshots, so a
run resumed in another process (after a restart, agent.approvals.resolve(),
agent.resume(), a session's pending turn) acts for the same caller. A call
that continues an unfinished checkpointed run must pass no principal (it then
continues as the saved one) or the same one; another caller's
send(message, { sessionId, principal }) throws LOUSHO_CONFIG_INVALID, so
its input never runs under someone else's identity. Checkpoints and snapshots
saved before this release have no principal and resume without one.
agent.fork() / AgentExecutor.fork() copy a run's checkpoint, principal
included: the fork is the same caller's run. A pending approval is not copied;
the fork asks again. session.fork() copies only the conversation: the fork
has no principal of its own, each of its turns runs with the principal its
send() passes, and a pending approval stays with the original session.
In-process sub-agents (the task tool and background tasks) act for the
lead's caller, also when their approval is
resumed. A remote sub-agent (remoteAgent()) is not told who
the caller is: the remote server's own route auth decides its principal (for
example the apiToken() service principal of the lead's token).
The principal, claims included, is stored in checkpoints and approval records,
which already hold the transcript; treat those stores as personal data. It is
not added to AgentEvents (permission.decision included), OpenTelemetry
spans or trace files, and error messages do not name callers.