> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vendo.run/llms.txt
> Use this file to discover all available pages before exploring further.

# Auth: principals, presets, and away calls

> How Vendo resolves the principal for each request, which auth preset fills the principal, actAs, and door OAuth seams, how away calls reach your API, and where orgs live.

Vendo mints no identity of its own. Every wire request resolves a `Principal`
through a seam you fill, and the `subject` it returns scopes threads, apps,
approvals, grants, activity, and runs for that request.

There are three seams: `principal` (request → user), `actAs` (auth material for
a user who is not at a browser), and `oauth` (the MCP door's identity adapter).
One auth preset fills all three.

## The principal

`principal(request)` is required. A bare `createVendo()` throws until you pass
it, or pass a preset that supplies it.

```ts theme={null}
principal: async (request) => {
  const user = await resolveSession(request);
  return user ? { kind: "user", subject: user.id } : null;
},
```

Return `null` and the request is refused with `forbidden` (403): the visitor has
no identity. Return a signed-in user's stable id and that request's data is
scoped to the subject for good.

## One preset, three seams

Pass a named preset as `auth` and you are done:

```ts theme={null}
import { authJs } from "@vendoai/vendo/auth/auth-js";
import { createVendo } from "@vendoai/vendo/server";

export const vendo = createVendo({ auth: authJs() });
```

Every preset is zero-argument in the standard case. It reads its own provider's
env variable and derives the principal's `display` from name/email claims in
the session token.

| Preset            | Session secret (env)                                                                          | Session source                                                   | Runtime SDK it lazy-loads |
| ----------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------- |
| `authJs()`        | `AUTH_SECRET`, then next-auth v4's `NEXTAUTH_SECRET`                                          | Auth.js session JWE (v5 only — v4 sessions resolve as anonymous) | `@auth/core`              |
| `clerk()`         | `CLERK_SECRET_KEY` (+ optional `CLERK_JWT_KEY`)                                               | `__session` cookie or `Authorization: Bearer`                    | `@clerk/backend`          |
| `supabase()`      | `SUPABASE_JWT_SECRET` (HS256, offline) and/or `SUPABASE_URL` (ES256 logins via GoTrue's JWKS) | `sb-*-auth-token` cookie or `Authorization: Bearer`              | `jose`                    |
| `auth0()`         | `AUTH0_DOMAIN` / `AUTH0_ISSUER_BASE_URL` (tenant JWKS)                                        | `Authorization: Bearer`                                          | `jose`                    |
| `jwt({ secret })` | none (a host-owned scheme has no vendor env, so `secret` is required)                         | `Authorization: Bearer`                                          | —                         |

Each preset lives on its own subpath: `@vendoai/vendo/auth/auth-js`,
`/auth/clerk`, `/auth/supabase`, `/auth/auth0`, `/auth/jwt`.

Install the runtime SDK as a direct dependency of your app. A copy npm nested
under another package is not resolvable from Vendo, and every request to the
wire then fails with `Module not found: Can't resolve '@auth/core/jwt'`.

**next-auth v4 is not supported.** v4 uses its own cookie names
(`next-auth.session-token`) and its own JWE derivation, so v4 sessions are
structurally unreadable here: signed-in users resolve as anonymous (the
preset logs one console hint when it sees a v4-named cookie), and away calls
into the host fail its session verification even though the doctor probe's
vendo-side round-trip passes. `vendo init` prints an advisory when it wires
`authJs()` onto a next-auth major-4 host. The secret fallback to
`NEXTAUTH_SECRET` exists for v5 hosts still using the legacy variable name —
it does not make v4 sessions readable. Upgrade to v5, or stay anonymous with
`--auth none` until then.

`jwt` shares the presets' options type, so `jwt()` type-checks, then throws
at construction telling you to pass `{ secret }`. A `secret` that is present but
resolves empty passes construction and throws later, on the first Bearer request
it has to verify.

Two options work on any preset. `secret` overrides the env-read secret.
`user(subject, claims)` swaps in your own subject→user resolution instead of the
provider's claims defaults; returning `null` means "subject unknown to host", and
away or MCP minting for that subject declines.

```ts theme={null}
import { authJs } from "@vendoai/vendo/auth/auth-js";
import { createVendo } from "@vendoai/vendo/server";
import { db } from "@/lib/db"; // yours

export const vendo = createVendo({
  auth: authJs({
    secret: () => process.env.AUTH_SECRET,
    user: async (subject) => {
      const user = await db.user.findUnique({ where: { id: subject } });
      if (!user || user.disabled) return null;
      return { display: user.name, email: user.email };
    },
  }),
});
```

`auth` is mutually exclusive with the per-seam trio. Supplying `auth` together
with `principal`, `actAs`, or `oauth` throws `VendoError("validation")` at
compose time.

### Notes per provider

* **supabase:** never use the anon key or a service-role token as the secret.
  Both are issued tokens. The secret is the key that signed them. Hosted
  projects on newer signing keys use ES256, and setting `SUPABASE_URL` is
  enough for that path: the preset verifies against
  `<url>/auth/v1/.well-known/jwks.json` via `jose`.
* **clerk and auth0:** present calls need nothing beyond the env variable. Away
  calls are the extra step, because the provider holds the session signing keys.
  See [Away calls](#away-calls-the-actas-seam).

## Let init wire it

`vendo init` reads `package.json` and picks a family:

| Dependency matches       | Preset     |
| ------------------------ | ---------- |
| `next-auth` or `@auth/*` | `authJs`   |
| `@clerk/*`               | `clerk`    |
| `@supabase/*`            | `supabase` |
| `@auth0/*`               | `auth0`    |

An interactive run confirms the detected family with one `[Y/n]`.
`--auth <preset>` answers it without the prompt:
`authJs`, `clerk`, `supabase`, `auth0`, `jwt`, or `none`.

Init writes the `auth:` line only when it creates the composition. On a re-run
against a composition you already have, init changes nothing about auth and you
add the one line yourself.

* **`--auth authJs|clerk|supabase|auth0` with the SDK in `package.json`:** fully
  wired, nothing stubbed.
* **Same flag, SDK missing:** wired, plus a stub. Install the runtime package
  from the table above before the first authenticated run. The preset fails loud
  until then.
* **`--auth jwt`:** nothing is wired. There is no vendor env variable for a
  host-owned scheme, so init prints the recipe and you add
  `auth: jwt({ secret: () => process.env.YOUR_SIGNING_SECRET })` by hand.
* **`--auth none`:** the composition gets a demo principal,
  `principal: async () => ({ kind: "user" as const, subject: "demo-user" })`.
  Every request resolves to the same subject. Replace it with a real session
  lookup, or a preset line, before production. Opening the MCP door on top of
  this principal needs an `oauth` seam beside it, not a preset. See
  [HostOAuthAdapter](/reference/mcp-door#hostoauthadapter).

Zero or several families detected, or a declined confirm, and init leaves
`principal` unwired and prints the exact line to add. `createVendo` throws until
it lands.

### Cross-check before you pass `--auth`

A dependency is only a hint. The signals that confirm it:

* **authJs:** `AUTH_SECRET` in `.env*`, an `auth.ts`/`auth.config.ts`, or an
  `app/api/auth/[...nextauth]` route.
* **clerk:** `CLERK_SECRET_KEY` / `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` in
  `.env*`, `<ClerkProvider>` in the layout, `clerkMiddleware` in
  `middleware.ts`.
* **supabase:** `SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_URL` or
  `SUPABASE_JWT_SECRET` in `.env*`, a `lib/supabase*` client module.
* **auth0:** `AUTH0_DOMAIN` or `AUTH0_ISSUER_BASE_URL` in `.env*`.
* **jwt:** none of the above, but the host's API verifies its own
  `Authorization: Bearer` HS256 tokens with a shared secret.

An agent driving init should hand the decision back to the human when several
providers are detected (one may be auth, the other just a database client), when
the app clearly has login but nothing is detected, when a secret has to be
created, or when `--auth none` is on the table and nobody has confirmed the
product has no per-user identity to distinguish.

## Away calls: the `actAs` seam

Scheduled automations, webhook-triggered runs, and MCP door calls carry no
browser session. They reach your host API through `actAs`, which mints auth
material for the principal before each outbound call. Present calls need none of
this. A user is in the room, the fetch is same-origin, and the inbound cookie or
bearer already forwards.

An `auth` preset fills `actAs` for you. Reach for the per-seam presets below
when no shipped `auth` preset matches your setup, or when you are composing the
three seams by hand.

Presets fall into two shapes, depending on what the identity provider allows:

**Offline session minting.** Auth.js and Supabase let a host holding the session
secret mint a session token the provider's own verifier accepts. The preset
mints a fresh token per away call. No extra middleware in your app.

**Host-owned away tokens.** Clerk and Auth0 sign sessions with RS256 private
keys you do not hold, so offline minting is impossible. These presets ship two
halves: a producer that signs a short-lived `VendoAway` HS256 token, and a
verify middleware you mount on your host app to trade that token for verified
identity headers your API code reads.

| Preset             | Provider           | Shape                                 |
| ------------------ | ------------------ | ------------------------------------- |
| `authJsPreset`     | Auth.js / NextAuth | Offline session-JWE via `@auth/core`  |
| `supabasePreset`   | Supabase Auth      | Offline HS256 with project JWT secret |
| `clerkPreset`      | Clerk              | Host-owned away token + middleware    |
| `auth0Preset`      | Auth0              | Host-owned away token + middleware    |
| `genericJwtPreset` | Anything HS256     | Configurable secret, claims, header   |

### Wire a per-seam preset

These presets ship in `@vendoai/actions`, a separate install from
`@vendoai/vendo`:

```bash theme={null}
npm install @vendoai/actions
```

Import the one you want and hand it to `createVendo` as `actAs`. Each closes
over a secret you provision and returns the `ActAs` function Vendo calls before
every away tool call. Clerk and Auth0 return a preset object instead; pass its
`actAs` half.

```ts theme={null}
import { createVendo } from "@vendoai/vendo/server";
import { authJsPreset } from "@vendoai/actions/presets/auth-js";
import { resolvePrincipal } from "@/lib/auth"; // yours

export const vendo = createVendo({
  principal: resolvePrincipal,
  actAs: authJsPreset({
    secret: process.env.AUTH_SECRET!,
    // Cookie name doubles as the JWE salt. Match your Auth.js config.
    cookieName: "authjs.session-token",
    secureCookie: process.env.NODE_ENV === "production",
  }),
});
```

Supabase takes the project JWT secret and the claims to stamp on each minted
token:

```ts theme={null}
import { supabasePreset } from "@vendoai/actions/presets";

actAs: supabasePreset({
  secret: process.env.SUPABASE_JWT_SECRET!,
  role: "authenticated",
  audience: "authenticated",
});
```

Clerk and Auth0 take a host-owned signing secret you generate once and share
with the verify middleware:

```ts theme={null}
import { clerkPreset } from "@vendoai/actions/presets";

const clerk = clerkPreset({
  secret: process.env.VENDO_AWAY_TOKEN_SECRET!,
});

// in createVendo:
actAs: clerk.actAs,
```

The generic preset covers anything else. Configure the claims map and the
headers your host API expects; the default is `Authorization: Bearer`.

```ts theme={null}
import { genericJwtPreset } from "@vendoai/actions/presets";

actAs: genericJwtPreset({
  secret: process.env.HOST_JWT_SECRET!,
  headers: (token) => ({ authorization: `Bearer ${token}` }),
  claims: (principal) => ({
    scope: "api:call",
  }),
});
```

### Mount the verify middleware (Clerk and Auth0)

These presets sign a host-owned token instead of a real Clerk or Auth0 session,
so your host API has to accept that token on away requests and turn it back into
a user. Each preset exports a Next.js middleware and an Express middleware.
Mount the one that fits.

```ts theme={null}
// middleware.ts (Next.js)
import { clerkPreset } from "@vendoai/actions/presets";

const clerk = clerkPreset({
  secret: process.env.VENDO_AWAY_TOKEN_SECRET!,
});

export const middleware = clerk.nextMiddleware;

export const config = { matcher: "/api/:path*" };
```

```ts theme={null}
// server.ts (Express)
import express from "express";
import { auth0Preset } from "@vendoai/actions/presets";

const auth0 = auth0Preset({
  secret: process.env.VENDO_AWAY_TOKEN_SECRET!,
});

const app = express();
app.use(auth0.expressMiddleware);
```

The middleware does three things on every request. It strips any
caller-supplied `x-vendo-away-*` headers, so callers cannot spoof identity by
setting them by hand. On a request carrying a valid `VendoAway` token it
verifies the token and injects the extracted subject on `x-vendo-away-*` headers
your API can trust. Forged, expired, and wrong-audience tokens are rejected
before your handler runs.

The `secret` the producer and the middleware read must be the same value; both
default to `VENDO_AWAY_TOKEN_SECRET`. Generate it with
`openssl rand -base64 32`, set it for both the Vendo runtime and the host API,
and rotate by redeploying both halves.

### The impersonation guard

Vendo compares the grant's `subject` to the current principal's `subject` before
invoking `actAs`. On a mismatch the call fails closed with an
`act-as-subject-mismatch` outcome and no outbound request is made. This covers
away automations and MCP door calls alike, on every preset. You do not wire it.

Away execution also needs a standing grant bound to the running app, captured
while the user was present. If `actAs` returns `null` for a run, the step fails
closed, the run terminates, and nothing reaches the host API. Each attempt is
audited with its disposition: `minted`, `declined`, `mismatch`, or `error`.

### Token caching

Every preset caches minted tokens in memory until just before expiry. Tokens
live 300 s by default and refresh 30 s early. Cache keys include a
fingerprint of the signing secret, so rotating the secret invalidates the cache
immediately and the next away call mints against the new one. Expired entries
are dropped on write, so the cache stays bounded by the number of active
principals.

## Orgs and the `vendo:` namespace

`Principal.kind` is `"user"` or `"org"`. Your `principal(req)` resolver may only
return `kind: "user"`. The wire rejects a resolver-produced `kind: "org"`
principal loudly, because organization context is a
[Vendo Cloud](/deploy/vendo-cloud) capability rather than something an OSS host
resolves directly.

Subjects starting with `vendo:` are reserved for Vendo-owned identities. Two are
defined today:

* `vendo:webhook:<source>` — webhook deliveries execute under this subject when
  no principal is otherwise attached to the request.
* `vendo:org:<orgId>` — reserved for Vendo Cloud organization workspaces. The
  OSS wire never mints it.

A resolver that returns a `vendo:*` subject, or a `kind: "org"` principal, is
rejected at the wire with a `validation` error. Mint your own subjects under
whatever prefix your product uses. Audit readers and dashboards that filter on
webhook subjects should match `vendo:webhook:<source>`, not the retired bare
`webhook:<source>` form.

Sharing apps, approvals, and grants under one org subject is not an OSS wire
feature. The self-hosted wire answers `cloud-required` (HTTP 402) on every
`/orgs` route, and on any `/approvals` or `/grants` request carrying an `org`
param, regardless of `VENDO_API_KEY`. `/status` reports no `orgs` block.
Organization
workspaces live in [Vendo Cloud](/deploy/vendo-cloud), which manages accounts,
members, and keys server-side.

## Verify the wiring

```bash theme={null}
npx vendo doctor
```

Doctor probes both seams live: present-credential forwarding
([`E-AUTH-001`](/deploy/troubleshooting#E-AUTH-001) through
[`E-AUTH-003`](/deploy/troubleshooting#E-AUTH-003)) and the actAs mint plus the
host verification round-trip
([`E-AUTH-004`](/deploy/troubleshooting#E-AUTH-004) through
[`E-AUTH-007`](/deploy/troubleshooting#E-AUTH-007)). For Clerk and Auth0 it also
round-trips a synthetic away token through your verify middleware, so a
mismatched secret surfaces before the first real away call.

Away calls failing with `not-implemented` mean `actAs` is not configured.
Failing with `act-as-subject-mismatch` means the grant belongs to a different
user than the current principal.
