Authentication

August 11, 2026 · View on GitHub

Agent-native apps use Better Auth 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: Magic-link-first Better Auth when outbound email is ready, with email/password fallback and 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.
<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"
        >magic link / 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>
.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;
}

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:

EnvironmentFirst-load behaviorEmail verification
Local devAuto-creates a throwaway dev account and signs you in (no login wall)Skipped by default (and when no email provider)
QA / previewNormal signup, but verification can be skipped so testers don't wait on emailSkip with AUTH_SKIP_EMAIL_VERIFICATION=1
ProductionMagic-link signup/login when outbound email is ready; email/password fallback otherwiseRequired for magic links (when an email provider is configured)

A few flags tune this; full details are in the 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.

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.

Local development sign-in {#local-development-sign-in}

On a loopback development server, the shared sign-in page puts Continue as local dev first. It creates or reuses the real Better Auth development account and session, so this is the same authenticated path used elsewhere.

Choose Show full sign in options to reveal normal signup, password, magic link, and social-provider options. If the local session cannot be created, the page reveals those options automatically so you can continue with standard authentication.

This convenience is available only when the browser connects through localhost, 127.0.0.0/8, or ::1 in development or test. It is disabled for deploy previews and hosted production. It is not an authentication bypass. Set AGENT_NATIVE_DISABLE_AUTO_DEV_ACCOUNT=1 to opt out and show the normal sign-in page in local development.

Better Auth (Default) {#better-auth}

By default, Better Auth powers authentication. When outbound email is ready, magic-link signup/login is the primary browser flow. Email/password remains the fallback when email is unavailable, or when AUTH_MAGIC_LINK=0 is set. It provides:

  • Magic-link registration and login, with email/password fallback
  • 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/magic-link — request a magic-link email
  • POST /_agent-native/auth/login — email/password fallback login
  • POST /_agent-native/auth/register — create account
  • POST /_agent-native/auth/logout — sign out

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 shapeCookie realm
Standalone appIsolated 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 subdomainsOpt 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 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.

Packaged Desktop workspace SSO {#desktop-workspace-sso}

The signed Agent Native Desktop SSO Canary composes hosted federation into one workspace sign-in for canonical first-party apps. Dispatch remains the identity authority, while every app mints and owns its normal local session. Electron keeps the authority session in a dedicated partition and copies only the target app's allowlisted session cookie into persist:app-<id>; provider credentials, identity tokens, and another app's cookies never enter renderer IPC.

The Dispatch-owned desktop.workspace-sso feature flag is default-off and controls availability only. Missing, malformed, unreadable, and explicit-Off state preserve ordinary per-app sign-in and sign-out. Nonce binding, signed exchange validation, exact origin and callback allowlists, authenticated-session requirements, session isolation, and revocation remain unconditional in every flag state. Custom and third-party apps are excluded. Canonical hosted apps recognize packaged Desktop requests without per-app identity-hub environment configuration. Ordinary browsers and self-hosted apps still require AGENT_NATIVE_IDENTITY_HUB_URL to opt in. Stable Desktop builds do not initialize the broker until a later release is separately approved. The Canary user-agent marker is an availability hint rather than remote attestation. Authentication and authorization never trust it; supervised acceptance binds the client through exact signed-artifact provenance. The Canary does not intercept anonymous app sign-in navigation. The user must explicitly choose workspace sign-in in Desktop Settings for the active registered app; ordinary app sign-in remains the default path.

QA Accounts {#qa-accounts}

Local development and tests skip password-signup email verification by default, so the email/password fallback can be tested without waiting on an inbox. To force verification locally while testing that fallback, set AUTH_SKIP_EMAIL_VERIFICATION=0.

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

AUTH_SKIP_EMAIL_VERIFICATION=1

When this flag is set, password fallback signup does not require email verification and the signup verification email is not sent. It does not change magic-link delivery. 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:

# 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 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 for the full session.orgId → AGENT_ORG_ID → SQL pipeline and the access guards. The 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.

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.

# 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:

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."}>

<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>
.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;
}

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.):

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:

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

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

{
  "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:

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:

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:

/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 (magic link, email/password, token, 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:

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

function SignInCta() {
  const onClick = () => {
    const ret = window.location.pathname + window.location.search;
    window.location.href = "/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 /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 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 /sign-in entrypoint and the bookmarked-path case) thread the return URL through the OAuth state. The legacy /_agent-native/sign-in alias remains accepted for older links. 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:

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}

VariablePurpose
BETTER_AUTH_SECRETSigning key for Better Auth (auto-generated if not set)
AUTH_SKIP_EMAIL_VERIFICATIONSet to 1 in QA/preview environments to let password-fallback signups proceed without verification; local dev/test skips by default
AUTH_MAGIC_LINKSet to 0, false, or off to force the email/password fallback even when outbound email is ready
AUTH_DISABLEDSet 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_ACCOUNTSet to 1 to disable localhost auto-sign-in on a fresh dev database
AUTH_MODElocal resolves CLI/agent identity only (which dev user pnpm action runs as); never a browser login bypass
COOKIE_DOMAINOpt into shared session cookies across same-database subdomains (see Cookie Realms)
AGENT_NATIVE_WORKSPACE1 runs in workspace mode — one shared session realm across workspace apps
AGENT_NATIVE_SHARE_COOKIE_DOMAINSet with COOKIE_DOMAIN to share one auth database across first-party subdomains
OAUTH_STATE_SECRETDedicated HMAC key for OAuth state envelopes (see Security — OAuth State Signing)
GOOGLE_SIGN_IN_CLIENT_IDPreferred low-scope Google OAuth client ID for app login
GOOGLE_SIGN_IN_CLIENT_SECRETPreferred low-scope Google OAuth secret for app login
GOOGLE_CLIENT_IDLegacy Google login fallback, and provider OAuth client ID for Google API integrations
GOOGLE_CLIENT_SECRETLegacy Google login fallback, and provider OAuth secret for Google API integrations
GITHUB_CLIENT_IDEnable GitHub OAuth
GITHUB_CLIENT_SECRETGitHub OAuth secret
ACCESS_TOKENStatic bearer fallback for MCP/connect clients; not browser auth
ACCESS_TOKENSComma-separated static bearer fallbacks for MCP/connect clients; not browser auth
A2A_SECRETShared secret for JWT-signed A2A cross-app identity verification and, when present, MCP OAuth access-token signing

What's next

  • Organizations, Teams & Permissions — the org-role vs. resource-role RBAC model, and a practical multi-user setup sequence
  • Multi-Tenancy — the org-management surface built on top of session.orgId
  • Security — Data Scoping — the session.orgId → AGENT_ORG_ID → SQL pipeline and access guards
  • Cross-App SSO — sharing sign-in across first-party hosted apps that each have their own auth database
  • Sharing — sharing individual resources between authenticated users and orgs
  • MCP Protocol — the MCP surface that Remote MCP OAuth authenticates into