ObjectStackObjectStack

Authentication Guide

Complete guide to implementing authentication in ObjectStack using plugin-auth with Better-Auth

Authentication Guide

Complete guide to implementing authentication in ObjectStack applications using the @objectstack/plugin-auth package powered by Better-Auth.

Table of Contents

  1. Overview
  2. Installation
  3. Basic Setup
  4. Authentication Methods
  5. OAuth Providers
  6. Advanced Features
  7. Client Integration
  8. API Reference
  9. Best Practices
  10. MSW/Mock Mode

Overview

The @objectstack/plugin-auth package provides enterprise-grade authentication and identity management for ObjectStack applications. It's built on top of Better-Auth, a modern authentication library, and seamlessly integrates with ObjectStack's kernel architecture.

Key Features

  • Email/Password Authentication - Traditional username/password login
  • OAuth Providers - Google, GitHub, and more
  • Session Management - Automatic session handling with configurable expiry
  • Password Reset - Email-based password reset flow
  • Email Verification - Email verification workflow
  • 2FA - Two-factor authentication
  • Passkeys - WebAuthn/Passkey support
  • Magic Links - Passwordless authentication
  • Organizations - Multi-tenant support
  • ObjectQL Integration - Native ObjectStack data persistence (no ORM required)

Architecture

The plugin uses a direct forwarding architecture where all authentication requests are forwarded to Better-Auth's universal handler. This ensures:

  • Full compatibility with all Better-Auth features
  • Minimal custom code to maintain
  • Easy updates when Better-Auth releases new features
  • Type-safe API access via authManager.api

Installation

Install the plugin in your ObjectStack project:

pnpm add @objectstack/plugin-auth

The plugin requires Better-Auth as a peer dependency, which will be automatically installed.


Basic Setup

1. Environment Variables

Set up your authentication secret in .env:

# Required: Secret for session token encryption
AUTH_SECRET=your-super-secret-key-min-32-chars

# Optional: OAuth providers (if using)
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret

Important: Never commit AUTH_SECRET to version control. Use a strong random string (minimum 32 characters).

2. Plugin Configuration

Add the plugin to your kernel configuration:

import { ObjectKernel } from '@objectstack/core';
import { AuthPlugin } from '@objectstack/plugin-auth';
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';

const kernel = new ObjectKernel({
  plugins: [
    // HTTP server (optional — auth works without it in MSW/mock mode)
    new HonoServerPlugin({
      port: 3000,
    }),
    
    // Authentication plugin
    new AuthPlugin({
      secret: process.env.AUTH_SECRET,
      baseUrl: 'http://localhost:3000',
    }),
  ]
});

await kernel.start();

That's it! Your authentication endpoints are now available at /api/v1/auth/*.

MSW/Mock Mode: AuthPlugin does not require an HTTP server. When HonoServerPlugin is absent, the plugin gracefully skips route registration and still registers the auth service. See MSW/Mock Mode below for details.

3. ObjectQL Data Persistence

The plugin automatically uses ObjectQL for data persistence. No additional database configuration is required - it works with your existing ObjectQL setup.

The plugin creates the following auth objects (using ObjectStack sys_ protocol names):

  • sys_user - User accounts (mapped from better-auth's user model)
  • sys_session - Active sessions (mapped from better-auth's session model)
  • sys_account - OAuth provider accounts (mapped from better-auth's account model)
  • sys_verification - Email/phone verification tokens (mapped from better-auth's verification model)

Note: better-auth uses hardcoded model names (user, session, etc.). The ObjectQL adapter automatically maps these to sys_-prefixed protocol names via AUTH_MODEL_TO_PROTOCOL. Client-side API routes (/api/v1/auth/*) are not affected — they do not expose object names.


Authentication Methods

Email/Password Authentication

Sign Up

// Client-side (using @objectstack/client)
import { ObjectStackClient } from '@objectstack/client';

const client = new ObjectStackClient({
  baseUrl: 'http://localhost:3000'
});

const result = await client.auth.register({
  email: 'user@example.com',
  password: 'securePassword123',
  name: 'John Doe'
});

console.log('User created:', result.data.user);
console.log('Access token:', result.data.token);

Sign In

const session = await client.auth.login({
  type: 'email',
  email: 'user@example.com',
  password: 'securePassword123'
});

console.log('Logged in:', session.data.user);

Sign Out

await client.auth.logout();

Get Current Session

const session = await client.auth.me();
console.log('Current user:', session.data.user);

Password Management

Request Password Reset

// Direct API call
const response = await fetch('http://localhost:3000/api/v1/auth/forget-password', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: 'user@example.com'
  })
});

Reset Password with Token

const response = await fetch('http://localhost:3000/api/v1/auth/reset-password', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    token: 'reset-token-from-email',
    password: 'newSecurePassword456'
  })
});

Email Verification

Send Verification Email

const response = await fetch('http://localhost:3000/api/v1/auth/send-verification-email', {
  method: 'POST',
  headers: { 
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${accessToken}`
  }
});

Verify Email

// User clicks link in email with token
const response = await fetch(`http://localhost:3000/api/v1/auth/verify-email?token=${token}`);

OAuth Providers

Configuration

Enable OAuth providers in your plugin configuration:

new AuthPlugin({
  secret: process.env.AUTH_SECRET,
  baseUrl: 'http://localhost:3000',
  providers: [
    {
      id: 'google',
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    },
    {
      id: 'github',
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }
  ]
})

OAuth Flow

1. Initiate OAuth Login

// Redirect user to OAuth provider
window.location.href = 'http://localhost:3000/api/v1/auth/authorize/google';

2. Handle Callback

Better-Auth automatically handles the OAuth callback at /api/v1/auth/callback/google and redirects the user back to your application with a session.


Advanced Features

Two-Factor Authentication (2FA)

Enable 2FA in your configuration:

new AuthPlugin({
  secret: process.env.AUTH_SECRET,
  baseUrl: 'http://localhost:3000',
  plugins: {
    twoFactor: true,  // Enable 2FA
  }
})

Enable 2FA for User

const response = await fetch('http://localhost:3000/api/v1/auth/two-factor/enable', {
  method: 'POST',
  headers: { 
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${accessToken}`
  }
});

const { qrCode, secret } = await response.json();
// Display qrCode to user for scanning with authenticator app

Verify 2FA Code

const response = await fetch('http://localhost:3000/api/v1/auth/two-factor/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    code: '123456'  // Code from authenticator app
  })
});

Passkeys (WebAuthn)

Enable passkey support:

new AuthPlugin({
  secret: process.env.AUTH_SECRET,
  baseUrl: 'http://localhost:3000',
  plugins: {
    passkeys: true,  // Enable passkey support
  }
})

Register a Passkey

const response = await fetch('http://localhost:3000/api/v1/auth/passkey/register', {
  method: 'POST',
  headers: { 
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${accessToken}`
  }
});

Authenticate with Passkey

const response = await fetch('http://localhost:3000/api/v1/auth/passkey/authenticate', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' }
});

Enable passwordless magic link authentication:

new AuthPlugin({
  secret: process.env.AUTH_SECRET,
  baseUrl: 'http://localhost:3000',
  plugins: {
    magicLink: true,  // Enable magic links
  }
})
const response = await fetch('http://localhost:3000/api/v1/auth/magic-link/send', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: 'user@example.com'
  })
});
// User clicks link in email
const response = await fetch(`http://localhost:3000/api/v1/auth/magic-link/verify?token=${token}`);

Organizations (Multi-Tenant)

Enable organization/team support:

new AuthPlugin({
  secret: process.env.AUTH_SECRET,
  baseUrl: 'http://localhost:3000',
  plugins: {
    organization: true,  // Enable organizations
  }
})

Client Integration

Using @objectstack/client

The official ObjectStack client has built-in auth methods:

import { ObjectStackClient } from '@objectstack/client';

const client = new ObjectStackClient({
  baseUrl: 'http://localhost:3000'
});

// Register
await client.auth.register({
  email: 'user@example.com',
  password: 'password123',
  name: 'John Doe'
});

// Login (auto-sets token)
await client.auth.login({
  type: 'email',
  email: 'user@example.com',
  password: 'password123'
});

// Now all subsequent requests include the auth token
const tasks = await client.data.find('task', {});

// Logout (clears token)
await client.auth.logout();

// Get current user
const session = await client.auth.me();

// Refresh token
await client.auth.refreshToken('refresh-token-value');

Direct API Calls

All endpoints are available at /api/v1/auth/*:

// Example: Login
const response = await fetch('http://localhost:3000/api/v1/auth/sign-in/email', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: 'user@example.com',
    password: 'password123'
  })
});

const data = await response.json();
const token = data.data.token;

// Use token in subsequent requests
const protectedResponse = await fetch('http://localhost:3000/api/v1/data/task', {
  headers: {
    'Authorization': `Bearer ${token}`
  }
});

API Reference

Authentication Endpoints

All endpoints are available under /api/v1/auth/*:

Email/Password

  • POST /api/v1/auth/sign-in/email - Sign in with email and password
  • POST /api/v1/auth/sign-up/email - Register new user
  • POST /api/v1/auth/sign-out - Sign out current user

Session

  • GET /api/v1/auth/get-session - Get current user session

Password Management

  • POST /api/v1/auth/forget-password - Request password reset email
  • POST /api/v1/auth/reset-password - Reset password with token

Email Verification

  • POST /api/v1/auth/send-verification-email - Send verification email
  • GET /api/v1/auth/verify-email - Verify email with token

OAuth

  • GET /api/v1/auth/authorize/[provider] - Start OAuth flow
  • GET /api/v1/auth/callback/[provider] - OAuth callback handler

Two-Factor Authentication

  • POST /api/v1/auth/two-factor/enable - Enable 2FA for user
  • POST /api/v1/auth/two-factor/verify - Verify 2FA code

Passkeys

  • POST /api/v1/auth/passkey/register - Register a passkey
  • POST /api/v1/auth/passkey/authenticate - Authenticate with passkey
  • POST /api/v1/auth/magic-link/send - Send magic link email
  • GET /api/v1/auth/magic-link/verify - Verify magic link

For complete API documentation, see the Better-Auth API Reference.


Best Practices

Security

  1. Use Strong Secrets: Generate a strong random secret for AUTH_SECRET (minimum 32 characters)

    # Generate a secure secret
    openssl rand -base64 32
  2. HTTPS in Production: Always use HTTPS for authentication endpoints in production

    new AuthPlugin({
      baseUrl: process.env.NODE_ENV === 'production' 
        ? 'https://api.example.com'
        : 'http://localhost:3000',
    })
  3. Environment Variables: Never commit secrets to version control

    # .env (not committed)
    AUTH_SECRET=your-secret-here
    GOOGLE_CLIENT_ID=your-client-id
  4. Session Expiry: Configure appropriate session expiry times

    new AuthPlugin({
      secret: process.env.AUTH_SECRET,
      baseUrl: 'http://localhost:3000',
      session: {
        expiresIn: 60 * 60 * 24 * 7,  // 7 days
        updateAge: 60 * 60 * 24,       // Update every 24 hours
      }
    })

User Experience

  1. Email Verification: Require email verification for sensitive operations
  2. Password Requirements: Enforce strong password policies
  3. 2FA for Admin: Require 2FA for administrative accounts
  4. OAuth Options: Provide multiple OAuth providers for convenience

Error Handling

try {
  await client.auth.login({
    type: 'email',
    email: 'user@example.com',
    password: 'wrong-password'
  });
} catch (error) {
  if (error.response?.status === 401) {
    console.error('Invalid credentials');
  } else {
    console.error('Login failed:', error.message);
  }
}

ObjectStack Field Naming

The plugin uses ObjectStack's sys_ prefix convention for protocol object names and snake_case for field names, which is required by the ObjectStack protocol:

  • Object names: sys_user, sys_session, sys_account, sys_verification (protocol names)
  • Field names: email_verified, created_at, user_id (snake_case)

better-auth internally uses camelCase model and field names (user, emailVerified, userId). The plugin bridges this gap using better-auth's official modelName / fields schema customisation API:

// Declared in the betterAuth() config via AUTH_*_CONFIG constants:
user:         { modelName: 'sys_user',         fields: { emailVerified: 'email_verified', … } },
session:      { modelName: 'sys_session',      fields: { userId: 'user_id', expiresAt: 'expires_at', … } },
account:      { modelName: 'sys_account',      fields: { providerId: 'provider_id', accountId: 'account_id', … } },
verification: { modelName: 'sys_verification', fields: { expiresAt: 'expires_at', … } },

The ObjectQL adapter factory (createObjectQLAdapterFactory) then uses better-auth's createAdapterFactory which automatically transforms all data and where-clauses using these mappings — no manual camelCase ↔ snake_case conversion is needed in the adapter.

Upgrade note: If you have custom adapters or plugins that reference auth objects by name, update them to use sys_user, sys_session, sys_account, sys_verification (or import from SystemObjectName constants).


MSW/Mock Mode

AuthPlugin is designed to work in both server and MSW/mock (browser-only) environments. This means you can develop and test authentication flows without running a real HTTP server.

How It Works

  • Server mode (HonoServerPlugin active): AuthPlugin registers HTTP routes at /api/v1/auth/* and forwards all requests to better-auth.
  • MSW/mock mode (no HTTP server): AuthPlugin gracefully skips route registration but still registers the auth service. The HttpDispatcher provides mock fallback responses for core auth endpoints.

Minimal Configuration for Mock Mode

import { ObjectKernel, DriverPlugin, AppPlugin } from '@objectstack/runtime';
import { ObjectQLPlugin } from '@objectstack/objectql';
import { InMemoryDriver } from '@objectstack/driver-memory';
import { AuthPlugin } from '@objectstack/plugin-auth';

const kernel = new ObjectKernel();

await kernel.use(new ObjectQLPlugin());
await kernel.use(new DriverPlugin(new InMemoryDriver(), 'memory'));

// AuthPlugin works without HonoServerPlugin — no HTTP server needed
await kernel.use(new AuthPlugin({
  secret: 'INSECURE_DEV_ONLY_mock_secret_do_not_use_in_production',
  baseUrl: 'http://localhost:5173',
}));

await kernel.bootstrap();

⚠️ Warning: The secret above is for local development only. In production, always use a strong random secret from an environment variable (process.env.AUTH_SECRET).

Mock Fallback Endpoints

When no auth service handler is registered and the legacy broker login is unavailable, HttpDispatcher.handleAuth() automatically provides mock responses for:

EndpointMethodDescription
sign-up/emailPOSTReturns mock user + session
sign-in/emailPOSTReturns mock user + session
loginPOSTLegacy login — returns mock user + session
registerPOSTAlias for sign-up
get-sessionGETReturns { session: null, user: null }
sign-outPOSTReturns { success: true }

This ensures that registration and sign-in flows do not return 404 errors in MSW/browser-only environments.

Note: In server mode with AuthPlugin loaded, the auth service handler takes priority and the mock fallback is never reached. The mock fallback only activates when AuthPlugin is not loaded (e.g. browser-only Studio builds where better-auth is unavailable).

Studio Kernel Factory

The Studio app runs in the browser, where the Node-only better-auth library cannot be bundled. Instead of loading AuthPlugin directly, Studio relies on HttpDispatcher's built-in mock fallback to handle auth endpoints in MSW mode:

// apps/studio/src/mocks/createKernel.ts
// No AuthPlugin needed — HttpDispatcher provides mock auth endpoints automatically
const kernel = new ObjectKernel();
await kernel.use(new ObjectQLPlugin());
await kernel.use(new DriverPlugin(driver, 'memory'));
// ...
await kernel.use(new MSWPlugin({ /* ... */ }));
await kernel.bootstrap();

Next Steps


Examples

Complete working examples are available in the repository:

On this page