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. The behavior depends on your environment:

  • Default: Better Auth with email/password + social providers. Onboarding page shown on first visit.
  • ACCESS_TOKEN: Simple shared token for production.
  • Custom: Bring your own auth via getSession callback.

Local development uses the same Better Auth flow as production — there is no dev-mode shim. The first time you load a template, you'll be sent to the onboarding page to create an account. Email verification is skipped by default in development (and when no email provider is configured), so signup is just an email + password.

Better Auth (Default)

When no ACCESS_TOKEN is set, 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 or token login
  • POST /_agent-native/auth/register — create account
  • POST /_agent-native/auth/logout — sign out

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:

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 ([email protected]) so they are easy to identify.

Social Providers

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

# Google OAuth
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.

OAuth State Signing

OAuth state envelopes (Google, Atlassian, Zoom) are HMAC-signed with OAUTH_STATE_SECRET. Set this to a random 32+ char value in production:

OAUTH_STATE_SECRET=$(openssl rand -hex 32)

If unset, the framework falls back to BETTER_AUTH_SECRET. A dedicated OAUTH_STATE_SECRET is recommended so rotating one secret doesn't invalidate the other. Reusing a third-party client secret (e.g. GOOGLE_CLIENT_SECRET) for OAuth state signing is not supported — a leak of the third-party secret would let attackers forge state envelopes.

redirect_uri query parameters on framework OAuth endpoints are validated against an allowlist (same-origin + framework /_agent-native/... paths). Custom OAuth flows in templates should use isAllowedOAuthRedirectUri(candidate, event) from @agent-native/core/server before signing state.

Organizations

Better Auth's organization plugin is built into the framework. 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 organization flows automatically through the system: session.orgIdAGENT_ORG_ID → SQL scoping. See the Security & Data Scoping docs for details.

Access Tokens

For simple deployments, set ACCESS_TOKEN (single) or ACCESS_TOKENS (comma-separated) as environment variables:

# Single token
ACCESS_TOKEN=my-secret-token

# Multiple tokens
ACCESS_TOKENS=token1,token2,token3

When access tokens are configured, users see a token login page. Sessions are cookie-based with 30-day expiry.

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

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:

/_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 viewer is 302'd 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 =
      "/_agent-native/sign-in?return=" + encodeURIComponent(ret);
  };
  return <Button onClick={onClick}>Sign in</Button>;
}

Bookmarked private paths

When an anonymous user navigates directly to a private path like /dashboard, the framework already serves the login page at that URL — after successful sign-in, the page reloads and the user lands on /dashboard. No special handling needed; this works for token, email/password, and Google OAuth.

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 as the sixth argument to encodeOAuthState:

const returnUrl = getQuery(event).return;
const state = encodeOAuthState(
  redirectUri,
  undefined,
  desktop,
  false,
  undefined,
  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 email/password signups proceed without verification; local dev/test skips by default
GOOGLE_CLIENT_ID Enable Google OAuth
GOOGLE_CLIENT_SECRET Google OAuth secret
GITHUB_CLIENT_ID Enable GitHub OAuth
GITHUB_CLIENT_SECRET GitHub OAuth secret
ACCESS_TOKEN Simple shared token auth
ACCESS_TOKENS Comma-separated shared tokens
AUTH_DISABLED Set to true to skip auth (infrastructure-level auth)
A2A_SECRET Shared secret for JWT-signed A2A cross-app identity verification