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
getSessioncallback.
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 sessionPOST /_agent-native/auth/login— email/password or token loginPOST /_agent-native/auth/register— create accountPOST /_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=1When 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-secretTemplates 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_idcolumns
The active organization flows automatically through the system: session.orgId → AGENT_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,token3When 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 |