Outer wraps Better Auth directly. .auth(config) mounts the handler at /api/auth/** and wires Better Auth’s database to whichever dialect you configured on new Outer({ db }).
Who is allowed to call what is a separate topic — see Permissions.
Before you begin
Register the Better Auth core tables — user, session, account, and verification — with schema().auth(). The admin plugin’s fields are included by default.
Enable auth
new Outer({ db: pglite() }).schema(v1_0).auth({ secret: process.env.AUTH_SECRET! }).build();.auth() can appear anywhere in the chain, as long as it comes before .build(). Its config is Omit<BetterAuthOptions, "database"> & { secret: string }, so every Better Auth option — plugins, emailAndPassword, trustedOrigins, and the rest — is accepted directly, with secret made required. Outer owns database and you cannot override it here.
Calling .auth() returns a new Outer whose context.auth type is narrowed to non-optional for everything chained after it.
Skip .auth() and context.auth is undefined, and /api/auth/** is not mounted. If a permission elsewhere still requires a session, .build() throws immediately and names the offending entries, rather than letting the first real request fail confusingly.
Outer sets no Better Auth defaults of its own — no default plugins, no default email options. Configure what you need explicitly.
Set baseURL for dynamic origins
baseURL defaults to the baseUrl you passed to new Outer({ baseUrl }). Override it per-call with .auth({ baseURL }).
It accepts a static string, or Better Auth’s DynamicBaseURLConfig, which derives the origin per request from the Host header:
.auth({
secret: process.env.AUTH_SECRET!,
baseURL: {
// "*" allows every host — fine for scaffolding and previews, but it means
// anyone can point this server at itself with a spoofed Host header.
// Once you have a real domain, restrict this to the hosts you serve,
// e.g. ["yourapp.com", "*.yourapp.com"].
allowedHosts: ["*"],
fallback: "http://localhost:3000", // must resolve to a real value — an unset env var silently disables it
},
})Reach for this when you deploy behind a dynamic or preview domain: Vercel previews, StackBlitz, Coolify preview deployments. A fixed baseUrl there makes Better Auth scope session cookies to the wrong origin, so sign-in appears to succeed but the session never persists.
Patterns match the full Host header including the port. A bare "localhost" does not match "localhost:3000" — use "localhost:*" when you want to restrict rather than allow every host.
Read the session
.auth() resolves the session once per request and shares it with every procedure, raw route, and permission check. No middleware, no getSession call:
.procedure("user.me", (base) => base.handler(({ context }) => context.user))context.user and context.session are null for anonymous callers, and always null when you never called .auth(). After .auth(), their types narrow to SessionUser | null and UserSession | null. Because the lookup is shared, a request costs exactly one session query no matter how many procedures or checks read it.
Use .middleware() for everything else you want on context — KV handles, feature flags, a tenant lookup — and leave auth out of it:
.middleware(async ({ next }) => next({ context: { kv: useStorage() } }))Fields you add with next({ context: { ... } }) are merged into the context of every procedure defined after it.
API keys
Use API keys for long-lived bearer tokens: MCP clients, CI, and server-to-server calls. They come from Better Auth’s @better-auth/api-key plugin, which is a separate package rather than part of better-auth core.
Install it:
bun add @better-auth/api-keyDeclare its table with schema().auth({ apiKeys: true }), then register the plugin:
import { apiKey } from "@better-auth/api-key";
.auth({
secret: process.env.AUTH_SECRET!,
plugins: [
apiKey({
// Required. Defaults to false, in which case a key never resolves to a
// session and calls fail with a misleading "You must be signed in".
enableSessionForAPIKeys: true,
// The plugin reads `x-api-key` by default. MCP clients send
// `Authorization: Bearer <key>`, so strip the scheme:
customAPIKeyGetter: (ctx) => {
const header = ctx.headers?.get("authorization");
return header?.toLowerCase().startsWith("bearer ") ? header.slice(7) : null;
},
}),
],
})A key authenticates as the user it belongs to. The plugin resolves it into a session before Outer builds the request context, so context.user, every permission, and ownerColumn behave exactly as they do for a cookie session. There is no second authorization path to keep in sync.
Manage keys through the plugin’s own endpoints at /api/auth/api-key/create, /list, and /delete. The plaintext key is shown once, at creation, and cannot be recovered — only its hash is stored.
CORS
Configure CORS on the constructor to let browser clients on other origins call /rpc/**, /api/auth/**, and the admin API. The origins are also folded into Better Auth’s trustedOrigins automatically:
new Outer({
db: pglite(),
cors: { origins: ["https://app.example.com"], credentials: true },
});Without cors, no Access-Control-Allow-Origin header is set. Same-origin requests and non-browser clients are unaffected either way. This only matters when your frontend and your Outer server are deployed on different origins.
Admin API
.admin() mounts an introspection and table-CRUD API under /rpc/_admin/** for admin dashboards. Outer Hub is the ready-made one — enable .admin(), allow its origin in cors, and manage the instance from hub.outer.now. The API itself:
| Procedure | Purpose |
|---|---|
_admin.meta |
Schema introspection |
_admin.migrations |
Migration status |
_admin.data.list/get/create/update/delete |
CRUD on any table |
It requires .auth(), and every procedure requires a signed-in user with an admin role. user.role may be comma-separated, as the Better Auth admin plugin stores it; configure the accepted roles with .admin({ roles }).
new Outer({ db: pglite(), cors: { origins: ["https://admin.example.com"] } })
.schema(v1_0) // built with schema().auth(), so the admin fields exist
.auth({ secret, plugins: [admin(), bearer()] })
.admin();For a dashboard hosted on another origin, list its origin in cors and prefer the Better Auth bearer plugin over cross-site cookies. See SPEC.md for the full procedure reference.
Next steps
- Permissions — guard procedures and resources with the session
- Outer Hub — the admin dashboard built on this API
.mcp()— expose procedures to agents, authenticated with API keys