---
title: "Authentication"
description: "Better Auth integration with email/password, social providers, organizations, and MCP bearer credentials."
---

# Authentication

Agent-native apps use [Better Auth](https://better-auth.com) for authentication with an account-first design. Users create an account on first visit and get real identity from day one.

## Overview {#overview}

Auth is configured automatically via `autoMountAuth(app)` in the auth server plugin. There are three modes:

- **Default:** Better Auth with email/password + social providers. Onboarding page shown on first visit.
- **Remote MCP OAuth:** Standard OAuth 2.1 for MCP hosts such as Claude Code and ChatGPT connectors.
- **Custom:** Bring your own auth via `getSession` callback.

<Diagram id="doc-block-f8rmmq" title="Three ways in, one session" summary="Browser visitors, MCP hosts, and custom providers all resolve to the same AuthSession that downstream scoping reads.">

```html
<div class="auth-modes">
  <div class="diagram-col">
    <div class="diagram-card">
      <span class="diagram-pill accent">Default</span
      ><strong>Better Auth</strong
      ><small class="diagram-muted"
        >email/password &middot; Google &middot; GitHub</small
      >
    </div>
    <div class="diagram-card">
      <span class="diagram-pill">Remote MCP OAuth</span
      ><strong>OAuth 2.1 + PKCE</strong
      ><small class="diagram-muted">Claude Code, ChatGPT connectors</small>
    </div>
    <div class="diagram-card">
      <span class="diagram-pill">Custom</span
      ><strong>getSession callback</strong
      ><small class="diagram-muted"
        >Clerk &middot; Auth0 &middot; Firebase</small
      >
    </div>
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-panel center">
    <span class="diagram-pill ok">AuthSession</span
    ><small class="diagram-muted">email &middot; orgId &middot; orgRole</small>
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-box">Request context &amp; data scoping</div>
</div>
```

```css
.auth-modes {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
}
.auth-modes .diagram-col {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.auth-modes .diagram-card {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 10px 12px;
}
.auth-modes .diagram-arrow {
  font-size: 22px;
  line-height: 1;
}
.auth-modes .center {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
}
```

</Diagram>

The browser flow is the same Better Auth flow everywhere — there is **no dev auth bypass**, and `getSession()` never falls back to a `local@localhost` sentinel. What changes between environments is signup friction, not the login wall:

| Environment      | First-load behavior                                                           | Email verification                              |
| ---------------- | ----------------------------------------------------------------------------- | ----------------------------------------------- |
| **Local dev**    | Auto-creates a throwaway dev account and signs you in (no login wall)         | Skipped by default (and when no email provider) |
| **QA / preview** | Normal signup, but verification can be skipped so testers don't wait on email | Skip with `AUTH_SKIP_EMAIL_VERIFICATION=1`      |
| **Production**   | Normal Better Auth signup/login                                               | Required (when an email provider is configured) |

A few flags tune this; full details are in the [Environment Variables](#environment-variables) table:

- `AGENT_NATIVE_DISABLE_AUTO_DEV_ACCOUNT=1` — use the normal signup page in local dev instead of the auto dev account.
- `AUTH_DISABLED=true` — skip login/signup entirely and run every request as one shared user (local dev / previews / demos only, never production with real users).
- `AUTH_MODE=local` — affects only CLI/agent identity (which dev user `pnpm action` runs as); it is **not** a browser login bypass.

<Callout id="doc-block-18jli2j" tone="warning">

`AUTH_DISABLED=true` runs **every request as one shared user**. Use it only for local dev, previews, or demos — never in production with real users, where it would expose all data to anyone.

</Callout>

## Better Auth (Default) {#better-auth}

By default, Better Auth powers authentication. It provides:

- Email/password registration and login
- Social providers (Google, GitHub, and 35+ others)
- Organizations with roles and invitations
- JWT tokens for API and A2A access
- Bearer token support for programmatic clients

Better Auth routes are mounted at `/_agent-native/auth/ba/*`. The framework also provides backward-compatible endpoints:

- `GET /_agent-native/auth/session` — get current session
- `POST /_agent-native/auth/login` — email/password login
- `POST /_agent-native/auth/register` — create account
- `POST /_agent-native/auth/logout` — sign out

## Cookie Realms {#cookie-realms}

The session cookie's realm follows the deployment shape, so apps that share a
database/origin share sign-in and apps that don't stay isolated:

| Deployment shape                            | Cookie realm                                                                                                         |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Standalone app                              | Isolated per app by slug (`APP_NAME`, or package name in local dev); stable `an` prefix in production                |
| Workspace mode (`AGENT_NATIVE_WORKSPACE=1`) | One shared realm — workspace apps share an origin and database                                                       |
| Custom same-database subdomains             | Opt into shared cookies with `COOKIE_DOMAIN`                                                                         |
| First-party hosted (`*.agent-native.com`)   | Isolated namespace per app (each has its own auth database); `COOKIE_DOMAIN=.agent-native.com` is ignored by default |

First-party hosted apps each have their own auth database, so cross-app sign-in
goes through [Cross-App SSO](/docs/cross-app-sso) rather than a shared cookie.
These deploys must provide `APP_NAME` or a derivable app URL (`APP_URL`,
`BETTER_AUTH_URL`, `VITE_BETTER_AUTH_URL`, `URL`, `DEPLOY_PRIME_URL`, or
`DEPLOY_URL`); otherwise startup fails instead of falling back to the shared
`an_session` name. To intentionally share one auth database across
subdomains, set `AGENT_NATIVE_SHARE_COOKIE_DOMAIN=1` alongside
`COOKIE_DOMAIN`.

## QA Accounts {#qa-accounts}

Local development and tests skip signup email verification by default, so you
can create real email/password accounts without waiting on an inbox. To force
verification locally while testing that flow, set `AUTH_SKIP_EMAIL_VERIFICATION=0`.

For hosted QA environments where testers need real accounts but should not wait
on email delivery, set:

```bash
AUTH_SKIP_EMAIL_VERIFICATION=1
```

When this flag is set, email/password signup does not require email
verification and the signup verification email is not sent. Use it only for QA
or preview environments, and name test accounts with a `+qa` address
(`name+qa@example.com`) so they are easy to identify.

## Social Providers {#social-providers}

Set environment variables to enable social login. Better Auth auto-detects them:

```bash
# Google OAuth
GOOGLE_SIGN_IN_CLIENT_ID=your-low-scope-sign-in-client-id
GOOGLE_SIGN_IN_CLIENT_SECRET=your-low-scope-sign-in-client-secret

# Backwards-compatible fallback, and provider OAuth credentials for templates
# that connect to Google APIs such as Gmail or Calendar.
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret

# GitHub OAuth
GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-client-secret
```

Templates that use `createGoogleAuthPlugin()` show a "Sign in with Google" page. The Google OAuth callback handles mobile deep linking for native apps automatically.

Prefer `GOOGLE_SIGN_IN_CLIENT_ID` / `GOOGLE_SIGN_IN_CLIENT_SECRET` for normal
app login. That client should request only identity scopes. Keep
`GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` for product integrations that need
Google API scopes, or as the legacy fallback when a deploy has not been split
yet. Mail and Calendar-style apps should use their own provider OAuth clients so
high-scope consent screens do not affect generic app sign-in.

### OAuth State Signing {#oauth-state-secret}

Set `OAUTH_STATE_SECRET` to a random 32+ char value in production so OAuth state envelopes (Google, Atlassian, Zoom) are HMAC-signed with a dedicated key independent of any third-party secret. See [Security — OAuth State Signing](/docs/security#oauth-state) for the full requirements and threat model.

## Organizations {#organizations}

The framework provides a built-in organization system. This is the framework's own `org/` module — backed by the `organizations` and `org_members` tables — not Better Auth's organization plugin, which is intentionally not registered. Every app supports:

- Creating organizations
- Inviting members with roles (`owner`, `admin`, `member`)
- Switching active organization
- Per-org data scoping via `org_id` columns

The active org is tracked on the session as `session.orgId`, and switching orgs changes the data the user and agent see. Data scoping itself happens further down the stack — see [Security & Data Scoping](/docs/security#data-scoping) for the full `session.orgId → AGENT_ORG_ID → SQL` pipeline and the access guards. The [Multi-Tenancy](/docs/multi-tenancy) docs cover the org-management surface. For the org-role vs. resource-role distinction, the full org REST route table, and a practical multi-user setup sequence, see [Organizations, Teams & Permissions](/docs/organizations-teams-permissions).

## Static MCP Bearer Tokens {#access-tokens}

`ACCESS_TOKEN` and `ACCESS_TOKENS` are not browser auth and do not make an app private. They remain only as static bearer credentials for MCP/connect clients that cannot use the OAuth flow.

```bash
# Single token
ACCESS_TOKEN=my-secret-token

# Multiple tokens
ACCESS_TOKENS=token1,token2,token3
```

Configuring these variables never renders a token login page for visitors. Web sign-in stays on Better Auth or your custom `getSession` provider.

## Remote MCP OAuth {#remote-mcp-oauth}

Every app's MCP endpoint can act as a standard protected MCP resource. OAuth-capable clients can be configured with only the remote MCP URL:

```text
https://mail.agent-native.com/mcp
```

Unauthenticated MCP requests return a `WWW-Authenticate` challenge pointing at `/.well-known/oauth-protected-resource`. The client then discovers the app's OAuth metadata, dynamically registers a public client, opens the app's authorization page, and exchanges an authorization code with PKCE for access and refresh tokens.

<Diagram id="doc-block-1pprto0" title="Remote MCP OAuth handshake" summary={"An OAuth-capable client bootstraps from just the MCP URL — challenge, discovery, dynamic registration, then a PKCE code exchange."}>

```html
<div class="mcp-flow">
  <div class="diagram-node">
    1 &middot; MCP request<br /><small class="diagram-muted">no token</small>
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-node warn">
    2 &middot; 401 challenge<br /><small class="diagram-muted"
      >WWW-Authenticate</small
    >
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-node">
    3 &middot; Discover metadata<br /><small class="diagram-muted"
      >.well-known</small
    >
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-node">
    4 &middot; Register client<br /><small class="diagram-muted"
      >dynamic, public</small
    >
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-node">
    5 &middot; Authorize + PKCE<br /><small class="diagram-muted"
      >code exchange</small
    >
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-node ok">
    6 &middot; Access + refresh<br /><small class="diagram-muted"
      >audience-bound</small
    >
  </div>
</div>
```

```css
.mcp-flow {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}
.mcp-flow .diagram-node {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 8px 12px;
}
.mcp-flow .diagram-arrow {
  font-size: 20px;
  line-height: 1;
}
```

</Diagram>

Access tokens are signed with `A2A_SECRET` when set, otherwise `BETTER_AUTH_SECRET`. They carry the signed user/org identity and the `mcp:read`, `mcp:write`, `mcp:apps`, and/or `offline_access` scopes, and are audience-bound to the exact MCP resource URL. `offline_access` is advertised for hosts such as ChatGPT that require it before retaining refresh access. Refresh tokens are stored only as hashes and rotate on every refresh. Tool calls and MCP Apps resource reads run inside the same request context as the signed-in user; the embedded MCP App iframe never receives raw OAuth tokens.

`npx @agent-native/core@latest connect <url> --client claude-code` writes the URL-only MCP entry for this standard flow. For clients that cannot perform remote MCP OAuth, use the Connect page or `npx @agent-native/core@latest connect --token <token>` fallback to write an explicit bearer-token entry.

## Bring Your Own Auth {#byoa}

Pass a custom `getSession` callback to use any auth provider (Clerk, Auth0, Firebase, etc.):

```ts filename="server/plugins/auth.ts"
import { createAuthPlugin } from "@agent-native/core/server";

export default createAuthPlugin({
  getSession: async (event) => {
    // Your custom auth logic here
    const session = await myAuthProvider.verify(event);
    if (!session) return null;
    return { email: session.email };
  },
  publicPaths: ["/api/webhooks"],
});
```

## Public Workspace Apps {#public-workspace-apps}

Workspace apps are internal by default. To let anonymous visitors load a public
site while keeping management pages behind auth, declare route access in
`apps/<id>/package.json`:

```json
{
  "agent-native": {
    "workspaceApp": {
      "audience": "public",
      "protectedPaths": ["/admin"]
    }
  }
}
```

For the inverse shape, keep the default internal audience and expose only
specific public pages:

```json
{
  "agent-native": {
    "workspaceApp": {
      "publicPaths": ["/", "/share"]
    }
  }
}
```

`publicPaths` and `protectedPaths` use prefix matching, so `"/admin"` also
covers `"/admin/users"`. These settings open page navigation only. Framework
routes (`/_agent-native/*`) and custom API routes (`/api/*`) still require auth
unless the app explicitly adds those prefixes to
`createAuthPlugin({ publicPaths: [...] })`.

## Session API {#session-api}

The session object returned by `getSession(event)` has this shape:

```ts
interface AuthSession {
  email: string; // User's email (primary identifier)
  userId?: string; // Better Auth user ID
  token?: string; // Session token
  name?: string; // Display name from the auth provider, when available
  image?: string; // Profile image from the auth provider, when available
  orgId?: string; // Active organization ID
  orgRole?: string; // Role in active org (owner/admin/member)
}
```

On the client, use the `useSession()` hook:

```ts
import {
  useSession,
} from "@agent-native/core/client/hooks"
function MyComponent() {
  const { session, isLoading } = useSession();
  if (isLoading) return <p>Loading...</p>;
  if (!session) return <p>Not signed in</p>;
  return <p>Hello, {session.email}</p>;
}
```

## Sign-In with Return URL {#sign-in-return-url}

Templates with **public pages** (share links, embeds, marketing pages) often need an in-page CTA that asks anonymous viewers to sign in and brings them back to the page they were on. The framework provides a single entry point for this:

```
/_agent-native/sign-in?return=<same-origin-path>
```

When an anonymous viewer hits this URL, the framework's login page is served. After a successful sign-in (any flow — token, email/password, or Google OAuth), the browser returns to `return`.

The `return` parameter is validated as a **same-origin path**. Network-path references (`//evil.com/...`), absolute URLs, `data:` / `javascript:` schemes, and embedded control characters all fall back to `/`. The validated path is reconstructed from the URL parser, not echoed back from the input.

**From a React component:**

```tsx
import { Button } from "@/components/ui/button";

function SignInCta() {
  const onClick = () => {
    const ret = window.location.pathname + window.location.search;
    window.location.href =
      "/_agent-native/sign-in?return=" + encodeURIComponent(ret);
  };
  return <Button onClick={onClick}>Sign in</Button>;
}
```

### Bookmarked private paths

Normal app pages use one impersonal, public-cacheable SSR shell for every visitor. `AppProviders` checks the session on the client; on a private path such as `/dashboard`, it redirects an anonymous visitor to `/_agent-native/sign-in?return=%2Fdashboard` before mounting private app UI. After sign-in, the visitor returns to `/dashboard`. Pass `isPublicPath` for public/SEO routes, or `sessionBypass` for a surface that authenticates through another scoped mechanism such as an MCP embed token.

How long that shell is cached is a deployment-wide setting — see [SSR Caching](/docs/deployment#ssr-caching) for `AGENT_NATIVE_SSR_CACHE`. Shortening or disabling the cache changes duration only; SSR still renders the anonymous branch with cookies stripped.

### Behind the scenes: Google OAuth

Both flows (the explicit `/_agent-native/sign-in` entrypoint and the bookmarked-path case) thread the return URL through the OAuth state. The state is HMAC-signed, so it can't be forged in transit. On the callback, the return URL is re-validated as same-origin before the redirect — so a leaked signing key still can't be turned into an open-redirect oracle.

If your template wraps `/_agent-native/google/auth-url` directly (e.g. mail and calendar templates do, to widen scopes), accept a `?return=<path>` query and forward it via the options-object form of `encodeOAuthState`:

```ts
const returnUrl = getQuery(event).return;
const state = encodeOAuthState({
  redirectUri,
  desktop,
  returnUrl: typeof returnUrl === "string" ? returnUrl : undefined,
});
```

The default `/_agent-native/google/auth-url` route does this automatically — only override if your template needs custom OAuth handling.

## Environment Variables {#environment-variables}

| Variable                                | Purpose                                                                                                                                      |
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `BETTER_AUTH_SECRET`                    | Signing key for Better Auth (auto-generated if not set)                                                                                      |
| `AUTH_SKIP_EMAIL_VERIFICATION`          | Set to `1` in QA/preview environments to let email/password signups proceed without verification; local dev/test skips by default            |
| `AUTH_DISABLED`                         | Set to `true` or `1` to skip login/signup; all requests run as one shared user (local dev/preview only — not for production with real users) |
| `AGENT_NATIVE_DISABLE_AUTO_DEV_ACCOUNT` | Set to `1` to disable localhost auto-sign-in on a fresh dev database                                                                         |
| `AUTH_MODE`                             | `local` resolves CLI/agent identity only (which dev user `pnpm action` runs as); never a browser login bypass                                |
| `COOKIE_DOMAIN`                         | Opt into shared session cookies across same-database subdomains (see [Cookie Realms](#cookie-realms))                                        |
| `AGENT_NATIVE_WORKSPACE`                | `1` runs in workspace mode — one shared session realm across workspace apps                                                                  |
| `AGENT_NATIVE_SHARE_COOKIE_DOMAIN`      | Set with `COOKIE_DOMAIN` to share one auth database across first-party subdomains                                                            |
| `OAUTH_STATE_SECRET`                    | Dedicated HMAC key for OAuth state envelopes (see [Security — OAuth State Signing](/docs/security#oauth-state))                              |
| `GOOGLE_SIGN_IN_CLIENT_ID`              | Preferred low-scope Google OAuth client ID for app login                                                                                     |
| `GOOGLE_SIGN_IN_CLIENT_SECRET`          | Preferred low-scope Google OAuth secret for app login                                                                                        |
| `GOOGLE_CLIENT_ID`                      | Legacy Google login fallback, and provider OAuth client ID for Google API integrations                                                       |
| `GOOGLE_CLIENT_SECRET`                  | Legacy Google login fallback, and provider OAuth secret for Google API integrations                                                          |
| `GITHUB_CLIENT_ID`                      | Enable GitHub OAuth                                                                                                                          |
| `GITHUB_CLIENT_SECRET`                  | GitHub OAuth secret                                                                                                                          |
| `ACCESS_TOKEN`                          | Static bearer fallback for MCP/connect clients; not browser auth                                                                             |
| `ACCESS_TOKENS`                         | Comma-separated static bearer fallbacks for MCP/connect clients; not browser auth                                                            |
| `A2A_SECRET`                            | Shared secret for JWT-signed A2A cross-app identity verification and, when present, MCP OAuth access-token signing                           |

## What's next

- [**Organizations, Teams & Permissions**](/docs/organizations-teams-permissions) — the org-role vs. resource-role RBAC model, and a practical multi-user setup sequence
- [**Multi-Tenancy**](/docs/multi-tenancy) — the org-management surface built on top of `session.orgId`
- [**Security — Data Scoping**](/docs/security#data-scoping) — the `session.orgId → AGENT_ORG_ID → SQL` pipeline and access guards
- [**Cross-App SSO**](/docs/cross-app-sso) — sharing sign-in across first-party hosted apps that each have their own auth database
- [**Sharing**](/docs/sharing) — sharing individual resources between authenticated users and orgs
- [**MCP Protocol**](/docs/mcp-protocol) — the MCP surface that Remote MCP OAuth authenticates into
