Adding Authentication — Agentuity Documentation

Adding Authentication

Add user authentication with Agentuity Auth

Protect your agents and routes with user authentication. Agentuity provides a first-party auth solution powered by Better Auth.

Auth in the Frontend, Routes, and Agents

const { user, isAuthenticated } = useAuth();
if (!isAuthenticated) return <LoginForm />;
return <div>Welcome, {user.name}</div>;

What You Get

  • Email/password authentication out of the box
  • Session and API key middleware for routes
  • Native ctx.auth support in agents
  • Organizations, teams, and roles via Better Auth plugins
  • JWT tokens for external service integration

Quick Start

Start with an Agentuity project, then add the auth packages:

agentuity create --name my-app
cd my-app
 
bun add @agentuity/auth better-auth drizzle-orm
bun add -D drizzle-kit

If your project does not already have DATABASE_URL, create a Postgres database:

agentuity cloud db create --name my_app_auth --region usc
agentuity cloud db get my_app_auth --show-credentials --json

Database names must be unique and can only use lowercase letters, digits, and underscores. If the create command fails with a name conflict, use a more specific name.

Generate a secret for AGENTUITY_AUTH_SECRET:

openssl rand -hex 32

Add the database URL and auth secret to .env:

DATABASE_URL=postgresql://...
AGENTUITY_AUTH_SECRET=...

Create .env.local with the local app URL. This keeps local auth callbacks separate from deployed app configuration:

AGENTUITY_BASE_URL=http://127.0.0.1:3500
BETTER_AUTH_URL=http://127.0.0.1:3500

Use the Local URL printed by agentuity dev, then restart the dev server after changing env vars.

If you use the DevMode public tunnel, use the Public URL printed by agentuity dev:

AGENTUITY_BASE_URL=https://<devmode-host>.agentuity-us.live
BETTER_AUTH_URL=https://<devmode-host>.agentuity-us.live

Create src/auth.ts:

typescriptsrc/auth.ts
import { createAuth } from '@agentuity/auth';
 
export const auth = createAuth({
  connectionString: process.env.DATABASE_URL,
});

Create src/schema.ts so Drizzle Kit can apply the auth tables:

typescriptsrc/schema.ts
export * from '@agentuity/auth/schema';

Create drizzle.config.ts:

typescriptdrizzle.config.ts
import { defineConfig } from 'drizzle-kit';
 
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error('DATABASE_URL is required');
 
export default defineConfig({
  dialect: 'postgresql',
  schema: './src/schema.ts',
  dbCredentials: { url: databaseUrl },
});

Mount the auth routes in your API router:

typescriptsrc/api/index.ts
import { Hono } from 'hono';
import type { Env } from '@agentuity/runtime';
import { mountAuthRoutes } from '@agentuity/auth';
import { auth } from '../auth';
 
const api = new Hono<Env>();
 
api.on(['GET', 'POST'], '/auth/*', mountAuthRoutes(auth));
 
export default api;

If app.ts mounts this router at /api, auth is available at /api/auth/*. If your router variable has a different name, mount the route on that object.

Apply the auth tables:

bunx drizzle-kit push

The config reads DATABASE_URL from your environment.

Verify the auth routes locally:

curl http://127.0.0.1:3500/api/auth/get-session

A signed-out app returns null.

For your deployed app, use the stable app URL from urls.app:

agentuity --json project show <project-id> | jq -r '.urls.app'

Set that value on your deployed project:

agentuity cloud env set "BETTER_AUTH_URL=https://<your-app-host>.agentuity.run" \
  --project-id <project-id>

Use the app URL here. The dashboard URL is only for managing the project.

If your deployed project does not already have the database URL and auth secret, set those too:

agentuity cloud env set "DATABASE_URL=postgresql://..." --secret \
  --project-id <project-id>
agentuity cloud env set "AGENTUITY_AUTH_SECRET=..." --secret \
  --project-id <project-id>

Deploy and verify the same route in your deployed app:

agentuity deploy --confirm
curl https://<your-app-host>.agentuity.run/api/auth/get-session

A signed-out app also returns null.

Server Setup

The Basics

Create an auth instance with a database connection string:

typescriptsrc/auth.ts
import { createAuth } from '@agentuity/auth';
 
export const auth = createAuth({
  connectionString: process.env.DATABASE_URL,
});

This gives you:

  • Email/password authentication
  • Session management
  • All default plugins (see below)

Default Plugins

Agentuity Auth includes these plugins automatically:

PluginPurpose
organizationMulti-tenancy with teams, roles, and invitations
jwtJWT token generation with JWKS endpoint
bearerBearer token auth via Authorization header
apiKeyAPI key authentication for programmatic access

Mounting Auth Routes

Mount the auth handler to expose sign-in, sign-up, session, and other endpoints:

typescriptsrc/api/index.ts
import { Hono } from 'hono';
import type { Env } from '@agentuity/runtime';
import { mountAuthRoutes } from '@agentuity/auth';
import { auth } from '../auth';
 
const router = new Hono<Env>();
 
// If app.ts mounts this router at /api, these are served at /api/auth/*
router.on(['GET', 'POST'], '/auth/*', mountAuthRoutes(auth));
 
export default router;

Advanced Configuration

typescriptsrc/auth.ts
import { createAuth } from '@agentuity/auth';
 
export const auth = createAuth({
  connectionString: process.env.DATABASE_URL,
  // Or: database: drizzleAdapter(db, { provider: 'pg', schema: authSchema }),
 
  skipDefaultPlugins: false, // Set true for full control over plugins
  apiKey: { enabled: true, defaultPrefix: 'ag_', defaultKeyLength: 64 },
  plugins: [], // Add custom Better Auth plugins
});

Middleware

Session Middleware

Protect routes with session-based authentication:

import { createSessionMiddleware } from '@agentuity/auth';
import { auth } from '../auth';
 
// Required authentication (returns 401 if not authenticated)
const authMiddleware = createSessionMiddleware(auth);
 
// Optional authentication (continues without auth context if not authenticated)
const optionalAuth = createSessionMiddleware(auth, { optional: true });
 
// Role-based access (returns 403 if user lacks required role)
const adminOnly = createSessionMiddleware(auth, { hasOrgRole: ['admin', 'owner'] });

Usage examples:

// Protect one route
router.get('/me', authMiddleware, async (c) => {
  const user = await c.var.auth.getUser();
  return c.json({ id: user.id, email: user.email, name: user.name });
});
 
// Allow both authenticated and anonymous access
router.get('/content', optionalAuth, async (c) => {
  const user = await c.var.auth.getUser().catch(() => null);
  return c.json({ premium: !!user });
});
 
// Admin-only route
router.get('/admin', adminOnly, async (c) => {
  return c.json({ message: 'Welcome, admin!' });
});

API Key Middleware

For programmatic access via API keys:

import { createApiKeyMiddleware } from '@agentuity/auth';
import { auth } from '../auth';
 
const apiKeyAuth = createApiKeyMiddleware(auth);
const optionalApiKey = createApiKeyMiddleware(auth, { optional: true });
const writeAccess = createApiKeyMiddleware(auth, { hasPermission: { project: 'write' } });
const fullAccess = createApiKeyMiddleware(auth, { hasPermission: { project: ['read', 'write'], admin: '*' } });

API keys are sent via headers: x-agentuity-auth-api-key: your_key or Authorization: ApiKey your_key

The Auth Context

When middleware authenticates a request, c.var.auth provides these methods:

MethodReturnsDescription
getUser()Promise<AuthUser>Get the authenticated user
getOrg()Promise<AuthOrgContext | null>Get active organization with full details
getOrgRole()Promise<string | null>Get user's role in active org
hasOrgRole(...roles)Promise<boolean>Check if user has one of the specified roles
hasPermission(resource, ...actions)booleanCheck API key permissions
getToken()Promise<string | null>Get the bearer token from request
authMethod'session' | 'api-key' | 'bearer'How the request was authenticated
apiKeyAuthApiKeyContext | nullAPI key details (if authenticated via API key)

Example:

router.get('/profile', authMiddleware, async (c) => {
  const user = await c.var.auth.getUser();
  const org = await c.var.auth.getOrg();
  const isAdmin = await c.var.auth.hasOrgRole('admin', 'owner');
 
  return c.json({
    user: { id: user.id, email: user.email, name: user.name },
    organization: org ? { id: org.id, name: org.name, role: org.role } : null,
    isAdmin,
  });
});

Client Setup

Creating the Auth Client

typescriptsrc/web/auth-client.ts
import { createAuthClient } from '@agentuity/auth/react';
 
export const authClient = createAuthClient();
 
// Export methods for use in components
export const { signIn, signUp, signOut, useSession, getSession } = authClient;

AuthProvider

Wrap your app with AuthProvider. Add AgentuityProvider only if you also use @agentuity/react transport hooks and need AuthProvider to mirror the bearer token into them:

tsxsrc/web/frontend.tsx
import { useState } from 'react';
import { AgentuityProvider } from '@agentuity/react';
import { AuthProvider, createAuthClient } from '@agentuity/auth/react';
import { App } from './App';
 
const authClient = createAuthClient();
 
export function Root() {
  const [authHeader, setAuthHeader] = useState<string | null>(null);
 
  return (
    <AgentuityProvider authHeader={authHeader}>
      <AuthProvider
        authClient={authClient}
        onAuthHeaderChange={setAuthHeader}
      >
        <App />
      </AuthProvider>
    </AgentuityProvider>
  );
}

useAuth Hook

Access auth state in your components:

import { useAuth } from '@agentuity/auth/react';
 
function Profile() {
  const { user, isAuthenticated, isPending, error } = useAuth();
 
  if (isPending) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!isAuthenticated) return <div>Please sign in</div>;
 
  return (
    <div>
      <h1>Welcome, {user.name}!</h1>
      <p>Email: {user.email}</p>
    </div>
  );
}

Sign In / Sign Up / Sign Out

import { signIn, signUp, signOut } from './auth-client';
 
// Email/password sign in
await signIn.email({ email, password });
 
// Email/password sign up
await signUp.email({ email, password, name });
 
// Sign out
await signOut();

These are Better Auth's standard client methods. See Better Auth's Client docs for useSession and error handling, and Email & Password for the sign-in and sign-up method options.

Build the UI

Agentuity Auth wires Better Auth into your project, but it does not require a specific sign-in screen. Use the client methods above from your own form, or use a Better Auth-compatible UI package.

If your app already uses shadcn/ui or HeroUI, Better Auth UI provides prebuilt auth screens. Point it at the same authClient and keep the server setup on this page.

Using Auth in Agents

The ctx.auth Interface

Auth is available natively on ctx.auth in agent handlers:

typescriptsrc/agent/protected/agent.ts
import { createAgent } from '@agentuity/runtime';
import { s } from '@agentuity/schema';
 
export default createAgent('protected', {
  schema: {
    input: s.object({ query: s.string() }),
    output: s.object({ result: s.string(), userId: s.string() }),
  },
  handler: async (ctx, { query }) => {
    if (!ctx.auth) {
      return { result: 'Please sign in', userId: '' };
    }
 
    const user = await ctx.auth.getUser();
    const org = await ctx.auth.getOrg();
 
    // Check organization roles
    if (org && await ctx.auth.hasOrgRole('admin')) {
      // Admin-only logic
    }
 
    return { result: `Hello ${user.name}`, userId: user.id };
  },
});

Agent-to-Agent Auth Propagation

When one agent calls another, auth context propagates automatically:

typescriptsrc/agent/hello/agent.ts
import { createAgent } from '@agentuity/runtime';
import poemAgent from '../poem/agent';
 
export default createAgent('hello', {
  handler: async (ctx, { name }) => {
    const user = ctx.auth ? await ctx.auth.getUser() : null;
 
    // Auth context passes through to poem agent automatically
    const poem = await poemAgent.run({
      userEmail: user?.email,
      userName: name,
    });
 
    return `Hello ${name}!\n\n${poem}`;
  },
});

Environment Variables

VariableDescriptionDefault
DATABASE_URLPostgreSQL connection stringRequired
AGENTUITY_AUTH_SECRETSecret for signing tokensFalls back to BETTER_AUTH_SECRET
AGENTUITY_CLOUD_BASE_URLPlatform-provided base URL for auth callbacksChecked before AGENTUITY_BASE_URL
AGENTUITY_BASE_URLBase URL for auth callbacksFalls back to BETTER_AUTH_URL
BETTER_AUTH_URLBetter Auth base URL, useful for local browser authUsed after Agentuity base URL vars

Auto-resolved trusted origins:

  • AGENTUITY_CLOUD_DOMAINS - Platform-set domains (deployment URLs, custom domains)
  • AUTH_TRUSTED_DOMAINS - Developer-set additional trusted domains (comma-separated)

Generate a secure secret:

openssl rand -hex 32

Database Configuration

Connection String (Simplest)

Provide the connection string and Agentuity configures Better Auth with the default plugins:

import { createAuth } from '@agentuity/auth';
 
export const auth = createAuth({
  connectionString: process.env.DATABASE_URL,
});

Bring Your Own Drizzle

If you have an existing Drizzle setup:

import { drizzle } from 'drizzle-orm/bun-sql';
import { drizzleAdapter } from '@agentuity/drizzle';
import * as authSchema from '@agentuity/auth/schema';
import * as appSchema from './schema';
 
const schema = { ...authSchema, ...appSchema };
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error('DATABASE_URL is required');
 
const db = drizzle(databaseUrl, { schema });
 
export const auth = createAuth({
  database: drizzleAdapter(db, { provider: 'pg', schema: authSchema }),
});

The @agentuity/auth/schema export provides all auth-related Drizzle tables (user, session, account, verification, organization, member, invitation, apiKey).

Built-in Features

Organizations & Teams

Create and manage organizations:

// Create an organization
const org = await auth.api.createOrganization({
  body: { name: 'My Team', slug: 'my-team' },
  headers: c.req.raw.headers,
});
 
// Get user's role in active org
const role = await c.var.auth.getOrgRole();
 
// Check role
if (await c.var.auth.hasOrgRole('admin', 'owner')) {
  // Admin actions
}

API Keys

Create API keys for programmatic access:

const result = await auth.api.createApiKey({
  body: {
    name: 'my-integration',
    userId: user.id,
    expiresIn: 60 * 60 * 24 * 30, // 30 days
    permissions: { project: ['read', 'write'] },
  },
});
console.log('API Key:', result.key); // Only shown once!

JWT & Bearer Tokens

// Get token in route handler
const token = await c.var.auth.getToken();
 
// JWKS endpoint: GET /api/auth/.well-known/jwks.json

Schema Setup

The default Agentuity Auth schema is exported from @agentuity/auth/schema. Re-export it from your project, then use your normal Drizzle workflow.

typescriptsrc/schema.ts
export * from '@agentuity/auth/schema';

Create a Drizzle config:

typescriptdrizzle.config.ts
import { defineConfig } from 'drizzle-kit';
 
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error('DATABASE_URL is required');
 
export default defineConfig({
  dialect: 'postgresql',
  schema: './src/schema.ts',
  dbCredentials: { url: databaseUrl },
});

For a quick setup, push the schema directly:

bunx drizzle-kit push

The Better Auth CLI migration commands are not the default path here. @agentuity/auth already ships the Drizzle schema; use Drizzle Kit to apply it.

For a team app, use drizzle-kit generate / drizzle-kit migrate with your normal migration review process.

Next Steps