Authentication

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

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.
Three ways in, one session
DefaultBetter Authmagic link / password · Google · GitHub
Remote MCP OAuthOAuth 2.1 + PKCEClaude Code, ChatGPT connectors
CustomgetSession callbackClerk · Auth0 · Firebase
AuthSessionemail · orgId · orgRole
Request context & data scoping

Browser visitors, MCP hosts, and custom providers all resolve to the same AuthSession that downstream scoping reads.

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 Magic-link signup/login when outbound email is ready; email/password fallback otherwise Required 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

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)

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

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 stay on normal per-app sign-in. Self-hosted apps need AGENT_NATIVE_IDENTITY_HUB_URL only for the remaining explicit Desktop or server-side federation path. Every Desktop build may initialize the broker when the per-device desktopSsoEnabled preference is true (the default); an explicit persisted false remains an opt-out. The desktop.workspace-sso Dispatch flag must also be enabled. The Canary user-agent marker no longer gates broker initialization; it only identifies the update channel. 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. For an eligible registered app, Desktop presents the workspace sign-in surface over the app's sign-in flow. If the rollout is unavailable or the app is not eligible, ordinary per-app sign-in remains available.

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 +autoz address (name+autoz@example.com) so they are easy to identify.

Email Verification Policy

AUTH_SKIP_EMAIL_VERIFICATION is a local and QA convenience — a hosted deployment ignores it. To state the policy for any environment, production included, declare it in config:

defineAppConfig({ auth: { requireEmailVerification: false } });

The deployment alias is AUTH_REQUIRE_EMAIL_VERIFICATION=0. A declared value outranks both AUTH_SKIP_EMAIL_VERIFICATION and the per-environment default.

false accepts an unverified address as a login credential — anyone can claim any email — so use it only where signup is already restricted another way. true on a deployment with no email provider disables password signup instead, because the verification could never be delivered.

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

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

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_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

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.

Remote MCP OAuth handshake
1 · MCP request
no token
2 · 401 challenge
WWW-Authenticate
3 · Discover metadata
.well-known
4 · Register client
dynamic, public
5 · Authorize + PKCE
code exchange
6 · Access + refresh
audience-bound

An OAuth-capable client bootstraps from just the MCP URL — challenge, discovery, dynamic registration, then a PKCE code exchange.

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

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

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

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

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

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

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 password-fallback signups proceed without verification; local dev/test skips by default
AUTH_REQUIRE_EMAIL_VERIFICATION Set to 0 or 1 to declare the password-signup verification policy for any environment, production included; outranks AUTH_SKIP_EMAIL_VERIFICATION
AUTH_MAGIC_LINK Set to 0, false, or off to force the email/password fallback even when outbound email is ready
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)
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)
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 — 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