Skip to main content

authentication

How auth works in agent-native apps. Use when wiring login/signup, configuring auth modes, setting up organizations, protecting routes, or debugging session issues.

Source facts

Repository
BuilderIO/agent-native
Last source activity
October 8, 2026 at 21:48
Detected SKILL.md language
English
Stars
7,119
Forks
646

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
authentication
description
How auth works in agent-native apps. Use when wiring login/signup, configuring auth modes, setting up organizations, protecting routes, or debugging session issues.
scope
dev
metadata
{"internal":true}
# Authentication ## Rule Auth is powered by **Better Auth** with account-first design. Every new user creates an account on first visit. Use `getSession(event)` to authenticate custom routes; actions are auto-protected. Normal app HTML and React Router page-data responses are one impersonal, public-cacheable shell for every visitor. The client decides whether to render private UI or redirect to sign-in. ## Auth Modes | Mode | Behavior | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | **Development (default)** | Real Better Auth — same flow as production. There is **no auth bypass**. On first run the framework auto-creates a throwaway dev account and signs you in without printing its credentials (disable with `AGENT_NATIVE_DISABLE_AUTO_DEV_ACCOUNT=1`), so you are not stuck at a login wall. `getSession()` returns the signed-in user or `null` — it never falls back to a sentinel identity. | | **Production (default)** | Magic-link-first Better Auth when outbound email is ready, with email/password fallback and social providers (Google, GitHub). Organizations built in. | | **`AUTH_MODE=local`** | **Not** a browser auth bypass, and never returns `local@localhost`. It only affects CLI/agent identity: it lets `pnpm action` / the local agent loop auto-bind to the single real signed-in dev user from the `sessions` table (see `scripts/dev-session.ts`). Browser login is unchanged. | | **`AUTH_SKIP_EMAIL_VERIFICATION=1`** | QA/preview escape hatch for password-fallback accounts. Signup skips email verification and does not send the signup verification email. Local dev/test skips verification by default; set `AUTH_SKIP_EMAIL_VERIFICATION=0` only when testing verification itself. It does not change magic-link delivery. Use `+qa` emails for test accounts. | | **`auth.requireEmailVerification`** | Declares the password-signup verification policy for any environment, production included, via `defineAppConfig({ auth: { requireEmailVerification: false } })` or `AUTH_REQUIRE_EMAIL_VERIFICATION=0`. A declared value outranks `AUTH_SKIP_EMAIL_VERIFICATION` and the per-environment default. `false` accepts an unverified address as a login credential; `true` with no email provider disables password signup instead of stranding accounts on a verification nobody can deliver. | | **`AUTH_MAGIC_LINK=0`** | Force the email/password fallback even when outbound email is ready. | | **`AUTH_DISABLED=true`** | Skip login/signup entirely — every request runs as `dev@local.test`. For local dev, cloud previews, and internal demos only; not for production with real users. | | **`ACCESS_TOKEN` / `ACCESS_TOKENS`** | Static bearer fallback for MCP/connect clients that cannot use OAuth. Not browser auth and never a token login page. | | **Custom** | Pass your own `getSession` to `autoMountAuth(app, { getSession })`. | Auth emails (verify signup, reset password, magic link, email change) and org invites render through `renderTransactionalEmail`. An app changes their copy or design with `overrideTransactionalEmail(id, render)` from a server plugin, never by editing `better-auth-instance.ts` call sites. See `/docs/deployment#email-templates`. ## Hosted Sign-In Pages and the Shared Wave - First-party server auth plugins use `createToolkitAuthPlugin` from `@agent-native/toolkit/app/auth/server`. It server-renders `AuthPage` and `ResetPasswordPage` before hydration. Direct Core `createAuthPlugin` calls leave generic fallback markup in the response and can flash before React replaces it. - `AuthPage` uses Toolkit's full-page `WaveBackground` for every auth view. The Agent-Native homepage hero and Calendar booking use the same renderer: Calendar's animated FFT ocean wave. Keep the background empty while WebGPU support is checked and when the ocean renderer is unavailable; never show a WebGL or Starfield fallback. The homepage positions the wave inside its hero section, and hosted auth pages lift it above the lower marketing copy. Do not substitute a gradient, a signup-only strip, or a copied renderer. `StarfieldBackground` is only a compatibility export for older callers. - Hosted marketing apps keep auth enabled at `/` so the public root response contains the full server-rendered sign-in page. Keep session decisions out of public SSR; `RequireSession` resolves signed-in app navigation in the client. > **Never** use `local@localhost` as a fallback identity in app code > (`getRequestUserEmail() ?? "local@localhost"`, `session?.email ?? "local@localhost"`, > etc.). There is no dev auth shim. That pattern pools every unauthenticated > request into one shared tenant and caused the 2026-04-29 credentials leak. > When there is no session, **throw or return 401** — never substitute a > sentinel. Enforced by `scripts/guard-no-localhost-fallback.mjs`. ## Remote MCP OAuth Every app's `/mcp` endpoint is also a standard protected MCP resource. OAuth-capable hosts connect with the remote MCP URL only, receive a `WWW-Authenticate` challenge, discover `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server`, then use a validated Client ID Metadata Document when the client id is an HTTPS URL or dynamically register a public client otherwise. URL-like client ids never fall back to dynamic registration when their metadata is missing or invalid. Clients complete authorization-code + PKCE at `/mcp/oauth/authorize` / `/mcp/oauth/token`. Access tokens are audience-bound to the exact MCP URL and carry user/org identity plus `mcp:read`, `mcp:write`, `mcp:apps`, and/or `offline_access`; advertising `offline_access` lets hosts such as ChatGPT retain refresh access. Refresh tokens are stored hashed and are not rotated: a refresh returns the same token, with no fixed expiry. MCP OAuth and connect tokens carry the org chosen when they were issued, so `verifyAuth` and the token endpoint re-check live org membership on every use. A removed member gets a 401 or `invalid_grant`; a failed check answers a retryable 503. Offboarding revokes their MCP refresh and connect tokens instead of transferring them. Connect mints MCP OAuth access tokens too, whatever `A2A_SECRET` holds. Resolve the audience of every bearer this app mints through `getMcpOAuthIssuer` / `resolveMcpOAuthIssuer`, whose resources are the audiences `verifyAuth` accepts, never from a URL built from request headers. Org service tokens carry a credential version earlier verifiers reject, because those admit any MCP OAuth token as a verified user. `verifyAuth` classifies a bearer by the credential it claims to be: OAuth access tokens and both connect formats are verified and admitted through one path before any A2A rule runs, and one that fails verification never reaches the A2A checks. Earlier A2A-format connect tokens verify only with the deployment `A2A_SECRET` and take their identity from their stored row; never let organization-secret or organization-principal rules see them. A refused bearer token gets a typed `reason` in the 401 body and, on `/mcp`, an `error_description` in the challenge. Cross-app A2A tokens are not re-checked, because their `org_id` is the signing app's assertion, and the A2A endpoint rejects MCP credentials. Keep `ACCESS_TOKEN` and `pnpm exec agent-native connect` for local stdio proxying and fallback clients. The CLI uses the OAuth-native URL-only entry for Claude Code/Claude Code CLI by default; use the Connect page or `npx @agent-native/core@latest connect --token <token>` when a client needs explicit bearer headers. ## Local → Real Account Migration Upgrading from `local@localhost` to a real account preserves SQL-backed workspace data. The built-in migration moves `application_state`, user-scoped `settings`, `oauth_tokens`, and any template table that uses `owner_email`. Templates with legacy global settings can provide `POST /api/local-migration` for one-time re-homing during the upgrade flow. ## Organizations Organizations are **framework-managed**, not handled by Better Auth's organization plugin (which is intentionally NOT registered). Org data lives in the framework's own `organizations`, `org_members`, and `org_invitations` tables. Every app supports creating orgs, inviting members, and role-based access (owner/admin/member). Owners and admins can require Google sign-in for an organization from Team settings or `PUT /_agent-native/org/auth-provider` with `{ provider: "google" }`. Enabling it revokes current Better Auth and legacy sessions; password signup, password login, and non-Google social sessions are rejected for that org. The active org flows automatically: `session.orgId` — resolved by `getOrgContext` from `org_members` plus the user's `active-org-id` setting (_not_ from a Better Auth session field) — → `AGENT_ORG_ID` → SQL scoping (see `security` skill). When an authenticated user has no org memberships, the framework auto-creates a default org (named after the user) the first time `getOrgContext` runs. This keeps org-scoped templates from showing a manual "create organization" step. The auto-create path skips users with pending invites or a matching `allowed_domain` org so they can join the intended team instead. Set `AUTO_CREATE_DEFAULT_ORG=0` only for deployments that intentionally want manual org creation. ### App-Specific Roles When a request needs "only some teammates may do this **inside this app**", the answer is per-app roles — **not** a second organization. App roles are an overlay on the one org membership, never a second roster. Three role systems coexist and never imply one another: the org role (`org_members.role`) says what a person may do to the *team*, an app role says what they may do inside *one app*, and a share role (`sharing` skill) says what they may do to *one row*. An app `admin` is not an org admin, and org handlers never read app roles. Declare the vocabulary once on the server and guard actions with it: ```ts import { defineAppRoles } from "@agent-native/core/org-team"; export const coachAccess = defineAppRoles({ appId: "coach", roles: ["member", "approver", "coach-admin"] as const, defaultRole: "member", permissions: { approve: ["approver", "coach-admin"] as const }, }); // in an action authorize: coachAccess.requireAny("coach-admin"), ``` For a role-configurable permission, guard instead with `authorize: coachAccess.requirePermission("approve")`. Roles are an unordered set, not a ladder — declaration order carries no meaning, and every guard names its accepted roles explicitly. `defaultRole` is **display only**: `requireAny` and `requirePermission` match explicit assignment rows and nothing else, so "nobody assigned this person" never reads as "granted", and widening the default cannot silently widen a guard. Org membership is a precondition, resolved in the same statement as the assignment, so a leftover assignment for a removed member can never authorize. Only org owners/admins may assign app roles; render the picker with `<TeamPage appRoles={descriptor} />`. In Settings, register a replacement `members` page that renders `<OrgMembersPage appRoles={descriptor} />` instead. Members may have multiple roles. `resolve` returns `{ status: "assigned", roles }`, and `assertAny` accepts any intersection with its requested roles. Declare permission keys and default role grants in the descriptor; owners/admins can override the role grants per organization from TeamPage or through the `list-app-permissions` / `set-app-permission-roles` actions. `requirePermission` uses the org override when present and code defaults otherwise. Unknown permission keys and roles are rejected; an empty override denies that permission to every role. `set-app-member-roles` replaces the member's full role set. Invitations can carry app roles, which apply after the member joins. `requireAny` validates at definition time — no roles, or a role outside the declared vocabulary, throws when the module loads rather than surfacing as an unexplained 403 later. When calling `resolve`/`assertAny` directly, note that an explicit `userEmail: null` / `orgId: null` means "this run has no identity" and does **not** fall back to the ambient request context; only `undefined` does. ### Stop And Confirm Before Creating Or Switching Organizations Vault credentials are scoped per organization and are **not** shared between them. A second organization therefore orphans every key synced under the first, and the only symptom is a missing-credential error somewhere else entirely, naming the key rather than the org change that caused it. So: **do not create an organization, repoint anyone's `active-org-id`, or migrate a user/roster/identity list into a new organization on your own initiative.** Stop, say plainly that it will orphan the existing organization's credentials, and get an explicit yes first — even when the user asked for something that seems to imply it ("use the real org user list", "align this to the Settings team view", "migrate my users"). The intended pattern is **one organization per workspace**, with every app sharing it. When a request needs real org members, add them to the existing organization; never provision a parallel one per app. `createOrganization()` logs a loud warning when it creates an additional org for an account that already belongs to one, and `setActiveOrgId()` logs one whenever it moves an account from one org to another, naming both orgs and the orphaned credentials. Treat either warning as a bug report against your own change, not as noise. Write `active-org-id` only through `setActiveOrgId(email, orgId, reason)` from `@agent-native/core` (`src/org/active-org.ts`). Calling `putUserSetting(email, "active-org-id", ...)` directly is how a roster migration silently repointed 21 accounts with nothing in the logs; the helper exists so that cannot happen again. Do not wrap normal app shells in `<RequireActiveOrg>` just to force setup. Use non-blocking org UI such as `InvitationBanner`, `OrgSwitcher`, and a `/team` route so users can accept invites, join domain-matched teams, or switch orgs without blocking the primary product experience. Place org UI inside the agent sidebar so the setup checklist, chat, and CLI stay usable during setup. ## Org Service Principals An org service token authenticates as `svc-<name>@service.<orgId>`, always an implicit `member`. Its governance record (`org/service-principal-policy.ts`) names an accountable `ownerEmail`, `team`, `riskTier`, `purpose`, a `lifecycle` (`active` | `suspended` | `retired`), and `allowedActions`. - Read the record through `evaluateServicePrincipal` at every entry point that admits a service identity; never query the table directly. - `allowedActions: null` is unrestricted, and a list is deny-by-default (exact names or a trailing `*` prefix). An empty list grants nothing. - The grant is enforced at the action boundary (`defineAction` wrapper and agent tool execution via `enforceServicePrincipalActionGrant`), so MCP, HTTP action routes, and delegated `ask-agent` runs all honor it. Agent built-ins are covered, so a list grant must name them. - No record means an ungoverned legacy principal: still active and unrestricted. Tightening it is an explicit admin act. - An unreadable record fails closed with a 503. Never map a read failure to "ungoverned" or "active". - `set-service-principal-policy` and `set-service-principal-lifecycle` are owner/admin only and not agent tool-callable. Suspending or retiring also aborts the principal's in-flight runs. Never let the agent or a service token widen its own grant. ## A2A Identity Set a distinct `A2A_SECRET` (same value) on apps that must verify each other's user identity. Only this deployment secret may sign a per-user A2A assertion; mint it from the authenticated request context. The shared organization `a2a_secret` proves organization scope only. Its subject/email claims are ignored, so it cannot impersonate a member or carry human approval into A2A or MCP. Keep each organization's secret different from `A2A_SECRET`. - Outbound calls sign user assertions with `A2A_SECRET` and verify them cryptographically at the receiver. - Organization-secret calls run as an organization principal without a user email; user-owned reads, tasks, and approvals need a verified user assertion. - Without a verifiable credential, production rejects A2A calls; local dev can remain open. ## Cross-App SSO (Dispatch identity hub) Each hosted `*.agent-native.com` app has its **own user store**, so "sign in once" is identity federation, not a shared cookie. **Dispatch is the identity authority.** - **Canonical hosted apps:** exact registered `*.agent-native.com` clients can show "Sign in with Agent-Native" and silently probe for an existing Dispatch session. The silent browser handoff is controlled by Dispatch's default-off `browser.identity-sso` feature flag. Self-hosted apps opt in with `AGENT_NATIVE_IDENTITY_HUB_URL=https://dispatch.agent-native.com`; unset means zero behavior change. - **Flow:** the app creates a bound state and PKCE verifier, then opens `GET <hub>/_agent-native/identity/authorize?response_type=code&app=&client_id=&redirect_uri=&state=&code_challenge=`. Dispatch authenticates the human and redirects back with only a short-lived, one-time authorization code. The app keeps the verifier in a callback-scoped HttpOnly cookie and redeems the code server-to-server at `/_agent-native/identity/token`; only that response contains the short-lived `A2A_SECRET`-signed identity assertion. No bearer token, JWT, or wildcard cookie is placed in a browser URL or shared across app domains. Canonical clients require an exact registered app ID, client ID, origin, and callback path; localhost remains available for development. The app verifies the assertion, **JIT-links strictly by verified email** (existing same-email user → reused unchanged; new email → created), then mints a normal local session. - **Organization federation:** the default-off `organization.cross-app-federation` flag separately enables signed org context. A source app registers its local organization with Dispatch using the stable `(identity_authority, identity_id)` mapping; the target app receives the same canonical `org_id`, signed org name, and signed role in the server-to-server assertion, then creates or links its local organization and membership by ID. Names, email domains, and browser cookies never identify an organization. Existing local organizations without a durable mapping are left unchanged rather than guessed or merged; link those explicitly as a migration. Unlinked local organizations remain usable during a Dispatch outage, while linked memberships fail closed until the authority is reachable; registration retries on a later org read. Browser SSO continues to use `A2A_SECRET`, but federation requests use a separate per-app credential: set `AGENT_NATIVE_IDENTITY_FEDERATION_SECRET` on each source app and the matching `AGENT_NATIVE_IDENTITY_FEDERATION_SECRET_<APP_ID>` on Dispatch. Never reuse the shared `A2A_SECRET` for the federation authority boundary. - **Silent browser handoff:** canonical auth pages may pass `prompt=none`. Dispatch returns `login_required` when no Dispatch session exists instead of showing another login page; the app then leaves the local sign-in form available. It never reveals whether a particular email or account exists. The feature flag is evaluated only at Dispatch, and unreadable or off rollout state fails closed. - **Invariant (do not break):** identity rows are only ever **added** - never modified, renamed, or deleted. Enabling SSO logs users out, but they always log back into the **same email-matched account with data intact**. Email is the identity trust boundary; org authorization crosses only as a verified, exact-ID assertion from the registered Dispatch authority. Local org mappings and memberships must not be inferred from names, domains, or user-controlled request bodies. - **Canary rollout:** ship with `browser.identity-sso` and `organization.cross-app-federation` Off → verify canonical pages keep direct sign-in available and a silent probe falls back locally → enable browser SSO for one test email in Analytics → enable org federation for one test organization → verify (logout → silent SSO → same pre-existing account, same canonical org ID, correct membership role, data intact, direct logins still work) → expand targeting gradually. Self-hosted apps still roll out one `AGENT_NATIVE_IDENTITY_HUB_URL` deployment at a time; rollback is disabling either flag or removing that env (instant, no data change). ### Packaged Desktop workspace sign-in For canonical first-party hosted apps, the packaged Desktop SSO Canary may compose the same federation into one workspace sign-in. Dispatch owns the default-off `desktop.workspace-sso` availability flag; every app still owns its local session. One explicit Settings action opens one interactive Dispatch ceremony, then provisions every currently eligible app. Later eligible webviews are provisioned lazily when they load. The flag is never an auth or authorization boundary. Keep nonce, signature, exact origin/callback, authenticated-session, app-binding, cookie allowlist, revocation, and credential-custody checks unconditional. When the flag is Off or unreadable, Desktop must leave ordinary per-app sign-in, sign-out, and Settings unchanged. Never extend the broker to arbitrary third-party sites, and never expose cookies, identity tokens, or provider credentials through IPC. Canonical hosted apps recognize packaged Desktop requests without per-app identity-hub environment configuration. A custom workspace app is eligible only when its Desktop config explicitly sets `workspaceSso: true`, its production origin is exact HTTPS, and Dispatch has an exact registration in
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub