---
title: ".auth()"
description: "Enable Better Auth with .auth(), read the session in procedures, issue API keys, and mount the admin API."
---

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

# .auth()

Outer wraps [Better Auth](https://www.better-auth.com) 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](/outer/permissions).

## Before you begin

Register the Better Auth core tables — `user`, `session`, `account`, and `verification` — with [`schema().auth()`](/schema/defining-schema#auth-tables). The admin plugin's fields are included by default.

## Enable auth

```ts
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:

```ts
.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:

```ts
.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:

```ts
.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`](https://better-auth.com/docs/plugins/api-key) plugin, which is a **separate package** rather than part of `better-auth` core.

Install it:

```bash
bun add @better-auth/api-key
```

Declare its table with [`schema().auth({ apiKeys: true })`](/schema/defining-schema#auth-tables), then register the plugin:

```ts
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:

```ts
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](/getting-started/hub) is the ready-made one — enable `.admin()`, allow its origin in `cors`, and manage the instance from [hub.outer.now](https://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 })`.

```ts
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](https://github.com/ilhajs/outer/blob/main/SPEC.md) for the full procedure reference.

## Next steps

- [Permissions](/outer/permissions) — guard procedures and resources with the session
- [Outer Hub](/getting-started/hub) — the admin dashboard built on this API
- [`.mcp()`](/outer/mcp) — expose procedures to agents, authenticated with API keys

Source: https://outer.now/outer/auth/index.mdx
