ObjectStackObjectStack

Authentication

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
  • Multi-Factor Authentication - TOTP two-factor, plus org-wide enforced MFA with a grace period and a Console enrollment/remediation flow (ADR-0069)
  • Magic Links - Passwordless authentication
  • Organizations - Multi-tenant support
  • ObjectQL Integration - Native ObjectStack data persistence (no ORM required)

Enterprise hardening (ADR-0069) — configured from Setup → Authentication, each setting backed by real runtime enforcement:

  • Password policy - Complexity (min character classes), history (no-reuse), expiry, and breached-password rejection via Have I Been Pwned (k-anonymity)
  • Account lockout & rate-limiting - Per-account lockout after N failed sign-ins (admin Unlock action) and per-IP throttling
  • Enforced MFA - Org-wide require_mfa with a configurable grace period; the session is gated from data access until the user enrolls
  • Session controls - Idle timeout, absolute lifetime cap, and concurrent-session limit per user
  • IP allowlist - Restrict authenticated access to CIDR ranges
  • Enterprise SSO - Admin-managed OIDC and SAML 2.0 trust list (see Social & Enterprise SSO)

See Enterprise Authentication Hardening for the full settings reference.

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 await authManager.getApi()

CLI device flow

os login uses a browser-based device flow in interactive terminals. The CLI requests a one-time code from POST /api/v1/auth/device/code, opens the Console approval page at /_console/auth/device?user_code=..., and polls POST /api/v1/auth/device/token until the user approves access. Device codes expire after the server-configured TTL (the CLI assumes a 10-minute / 600s default). The device flow requires plugins: { deviceAuthorization: true } in your AuthPlugin configuration.

The email/password path is still supported for CI and non-interactive shells:

os login --email user@example.com --password secret

New accounts can be created with:

os register
os register --email user@example.com --name "Jane Doe" --password secret

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
OS_AUTH_SECRET=your-super-secret-key-min-32-chars

# Optional: open-source Google login
# You can also configure these in Setup → Authentication.
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret

# Optional: lock auth settings from env. Env wins over Setup UI values.
OS_AUTH_SIGNUP_ENABLED=false
OS_AUTH_GOOGLE_ENABLED=true

Important: Never commit OS_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 { ObjectQLPlugin } from '@objectstack/objectql';
import { DriverPlugin } from '@objectstack/runtime';
import { InMemoryDriver } from '@objectstack/driver-memory';
import { AuthPlugin } from '@objectstack/plugin-auth';
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';

const kernel = new ObjectKernel();

// AuthPlugin depends on the ObjectQL engine for data persistence,
// so register ObjectQLPlugin and a driver first.
await kernel.use(new ObjectQLPlugin());
await kernel.use(new DriverPlugin(new InMemoryDriver(), 'memory'));

// HTTP server (optional — auth works without it in MSW/mock mode)
await kernel.use(new HonoServerPlugin({
  port: 3000,
}));

// Authentication plugin
await kernel.use(new AuthPlugin({
  secret: process.env.OS_AUTH_SECRET,
  baseUrl: 'http://localhost:3000',
}));

await kernel.bootstrap();

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/request-password-reset', {
  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}`);

Phone-Number Authentication (SMS OTP)

Phone numbers are a first-class sign-in identifier (better-auth phoneNumber plugin). Opt in per deployment:

OS_AUTH_PHONE_NUMBER_ENABLED=true   # or auth.plugins.phoneNumber in config

The plugin adds sys_user.phone_number (unique) and phone_number_verified. Phone-only employees are created by the admin create-user / import routes with an undeliverable placeholder email (u-<random>@placeholder.invalid) — never by OTP self-signup.

Phone + password sign-in

Always available once the plugin is on:

const response = await fetch('http://localhost:3000/api/v1/auth/sign-in/phone-number', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ phoneNumber: '+8613800000000', password: 'password123' })
});

OTP sign-in and self-service reset (requires SMS delivery)

The OTP surface opens only when a deliverable SMS service is configured (services.sms, Setup → Settings → SMS Delivery); without one these endpoints fail loudly with NOT_SUPPORTED. The public config advertises real availability as features.phoneNumberOtp, which is what the Console login UI gates its "Sign in with verification code" mode and the SMS reset branch on.

// Sign-in / verification: request a code, then verify (creates a session)
await fetch('/api/v1/auth/phone-number/send-otp', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ phoneNumber: '+8613800000000' })
});
await fetch('/api/v1/auth/phone-number/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ phoneNumber: '+8613800000000', code: '123456' })
});

// Self-service password reset over SMS
await fetch('/api/v1/auth/phone-number/request-password-reset', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ phoneNumber: '+8613800000000' })
});
await fetch('/api/v1/auth/phone-number/reset-password', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ phoneNumber: '+8613800000000', otp: '123456', newPassword: 'NewPass123!' })
});

Abuse hardening ships with the feature (SMS is a paid channel):

  • Per-number admission guard (always on): 60s cooldown + 5 sends per rolling hour per number, shared across the sign-in and reset flows and — in a cluster — across nodes via the shared KV. Rejected requests get an honest 429 with the retry window and never invalidate an already delivered code. Tune via AuthManager phoneOtp (cooldownSeconds / maxPerHour / allowedAttempts / expiresIn / otpLength).
  • Wrong-code attempts are capped at 3 (allowedAttempts), then the code is invalidated.
  • Per-IP rate limiting: the plugin ships a /phone-number/* default (10/min), and the auth settings rate_limit_max / rate_limit_window_seconds tighten all four OTP endpoints.
  • OTP codes never reach logs — the SMS service logs masked numbers and statuses only; request-password-reset always answers {status:true} so it cannot be used as an account-existence oracle.

The identity import endpoint's invite policy also supports phone-only rows when SMS is deliverable: the created account receives a credential-free invitation SMS and the employee completes first sign-in via phone OTP, then sets their own password.

SMS text customisation & localisation

The OTP and invitation bodies are localised and tenant-customisable: a sys_notification_template row for (auth.phone_otp | auth.phone_invite, channel 'sms', locale) wins — built-in English and Chinese rows are seeded once (never overwriting your edits) and can be changed under Setup → Notification Templates. The locale follows the deployment default (localization.locale setting) with a zh-CN → zh → en fallback chain; holes are {{code}}, {{appName}}, {{minutes}} (OTP) and {{appName}}, {{baseUrl}} (invitation). Template lookups are best-effort — an outage falls back to the built-in text and never blocks an OTP send.


OAuth Providers

Configuration

Enable OAuth providers in your plugin configuration:

new AuthPlugin({
  secret: process.env.OS_AUTH_SECRET,
  baseUrl: 'http://localhost:3000',
  // socialProviders is a record keyed by provider id (forwarded to better-auth).
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    },
    github: {
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    },
  },
})

Note: Use the socialProviders record above. The legacy providers: [...] array still type-checks but is not wired up at runtime — only socialProviders is forwarded to better-auth, so a providers array produces no working OAuth.

Zero-config Google (env only)

You can enable Google sign-in without any code by setting GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET (and leaving OS_AUTH_GOOGLE_ENABLED unset or not false). The plugin auto-registers Google as a social provider from these environment variables. Use the explicit socialProviders record for other providers or to override the env-derived configuration.

OAuth Flow

1. Initiate OAuth Login

// Recommended: use the client SDK, which posts to /sign-in/social and redirects.
// `callbackURL` defaults to the current page (window.location.href) — the SDK
// can't know your app's mount path, so it returns you where you started. Pass
// an explicit `callbackURL` to land elsewhere.
await client.auth.signInWithProvider('google');

// Equivalent direct API call: POST /sign-in/social returns a redirect URL.
const response = await fetch('http://localhost:3000/api/v1/auth/sign-in/social', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    provider: 'google',
    callbackURL: window.location.href,
  }),
});
const data = await response.json();
window.location.href = data.url ?? data.data.url;

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.


Enterprise Authentication Hardening (ADR-0069)

Beyond the core flows above, @objectstack/plugin-auth ships an enterprise hardening layer. Every toggle is configured from Setup → Authentication and is wired to real runtime enforcement (ADR-0049 — no setting ships without the code that reads it). Changes take effect immediately; no restart is required.

Password policy

SettingEffect
password_reject_breachedReject passwords found in the Have I Been Pwned corpus (k-anonymity range check — the password is never sent in full).
password_require_complexity / password_min_classesRequire a minimum number of character classes (upper / lower / digit / symbol).
password_history_countBlock reuse of the last N passwords.
password_expiry_daysForce a password change after N days; expired sessions are gated until the password is rotated.

Anti-abuse (lockout & rate-limiting)

SettingEffect
lockout_thresholdLock an account after this many consecutive failed sign-ins.
lockout_duration_minutesHow long a lockout lasts. Admins can clear it early with the Unlock action on the user record.
rate_limit_max / rate_limit_window_secondsPer-IP request throttle on auth endpoints.

Multi-factor (enforced MFA)

SettingEffect
mfa_requiredRequire MFA org-wide (also settable per organization via require_mfa).
mfa_grace_period_daysGrace window for existing users to enroll. After it elapses, the session is allowed to sign in but gated from data access until TOTP is enrolled — the Console surfaces a remediation overlay that walks the user through enrollment.

Sessions

SettingEffect
session_idle_timeout_minutesExpire a session after inactivity.
session_absolute_max_hoursHard cap on total session lifetime regardless of activity.
max_concurrent_sessions_per_userCap simultaneous sessions; the oldest is revoked (expired in place) when the cap is exceeded.

Network

SettingEffect
allowed_ip_rangesRestrict authenticated access to one or more IPv4 CIDR ranges. IP is extracted with trust-proxy awareness (x-forwarded-for / cf-connecting-ip / x-real-ip); requests whose IP can't be determined fail open.

How enforcement works. Password expiry and enforced MFA share a single session-validation gate: customSession stamps the session with an authGate posture, and the REST server + runtime dispatcher block gated sessions from data access (auth, health, and a small allowlist still pass so the user can remediate). This is why a non-compliant user can sign in but can't read data until they fix the issue.


Advanced Features

Two-Factor Authentication (2FA)

ObjectStack wires the better-auth two-factor (TOTP) plugin at the backend layer. The endpoints below let you build an opt-in 2FA experience in a custom account UI; for organization-wide enforced MFA, see Enterprise Authentication Hardening — the Console ships an enrollment/remediation flow for that path.

A complete opt-in 2FA UX still needs to handle:

  • enrollment (/two-factor/enable),
  • TOTP confirmation (/two-factor/verify-totp),
  • the twoFactorRedirect response returned by password sign-in, and
  • backup-code recovery.

For custom account UIs, enable the backend plugin in configuration:

new AuthPlugin({
  secret: process.env.OS_AUTH_SECRET,
  baseUrl: 'http://localhost:3000',
  plugins: {
    twoFactor: true,  // Enable backend two-factor endpoints
  }
})

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}`
  },
  body: JSON.stringify({
    password: currentPassword,
  }),
});

const { totpURI, backupCodes } = await response.json();
// Display totpURI as a QR code and show backupCodes once.

Verify 2FA Code

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

Passkeys (WebAuthn)

Not yet implemented. Passkey/WebAuthn support is not currently wired into AuthPlugin. The plugins: { passkeys: true } flag is accepted but no passkey plugin is registered, so /passkey/* endpoints are not available. This section will be updated once passkey support ships.

Enable passwordless magic link authentication:

new AuthPlugin({
  secret: process.env.OS_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}`);

Phone-Number Sign-In

Enable phone numbers as a first-class sign-in identifier (better-auth phone-number plugin). Opt-in — off by default:

new AuthPlugin({
  secret: process.env.OS_AUTH_SECRET,
  baseUrl: 'http://localhost:3000',
  plugins: {
    phoneNumber: true,  // Enable phone sign-in
  }
})

With the CLI serve command, OS_AUTH_PHONE_NUMBER_ENABLED=true flips the same flag. The plugin adds sys_user.phone_number (unique) and sys_user.phone_number_verified; the login UI discovers the capability via features.phoneNumber on GET /api/v1/auth/config.

Sign In with Phone + Password

const response = await fetch('http://localhost:3000/api/v1/auth/sign-in/phone-number', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    phoneNumber: '+8613800000000',
    password: 'password123'
  })
});

OTP Flows (Require an SMS Service)

The OTP surfaces — POST /phone-number/send-otp + POST /phone-number/verify (sign-in / verification) and POST /phone-number/request-password-reset + POST /phone-number/reset-password (self-service reset) — only work when an SMS service is wired (@objectstack/service-sms registers the sms kernel service). Without one, send-otp fails loudly instead of silently dropping the code; phone sign-in is then password-based only (an admin sets the password — see Admin User Management below). OTP requests are rate-limited per phone number to prevent SMS-pumping abuse.

Placeholder Emails for Phone-Only Accounts

better-auth requires a unique email per user, so accounts created with only a phone number get a generated placeholder address of the form u-<random>@placeholder.invalid. These are never deliverable (.invalid is an RFC 2606 reserved TLD), never derived from the phone number (so exports and logs can't leak it), and every auth mail callback (password reset, invitation, magic link) refuses them with a PLACEHOLDER_EMAIL error.

Organizations (Multi-Tenant)

Enable organization/team support:

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

Admin User Management

With plugins: { admin: true } (forced on when SCIM is enabled), platform admins get email-independent account management on the Users list — the create_user / set_user_password actions there drive these endpoints. All three routes are gated server-side on the platform-admin signals (isPlatformAdmin, the platform_admin position, or the legacy role scalar) and drive the better-auth pipeline, so created accounts get a real scrypt-hashed credential and can sign in immediately.

Create a User Directly

const response = await fetch('http://localhost:3000/api/v1/auth/admin/create-user', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: 'new.user@example.com',   // and/or phoneNumber (requires plugins.phoneNumber)
    name: 'New User',
    generatePassword: true,          // or pass an explicit `password`
    mustChangePassword: true         // default true
  })
});
// → { success, data: { user, temporaryPassword? } }

A generated temporary password is returned once in the response (the Users-list action reveals it in a one-shot dialog) and is never persisted or logged. While mustChangePassword is set, the user is blocked with 403 PASSWORD_EXPIRED on every protected route until they complete a password change — the same gate and Console redirect as password expiry.

Set / Reset a Password

POST /api/v1/auth/admin/set-user-password accepts { userId, newPassword } or { userId, generatePassword: true } plus the same mustChangePassword flag. It also provisions a credential account for SSO-onboarded users who never had a local password.

Bulk Import Users

POST /api/v1/auth/admin/import-users accepts the same payload shapes as the generic data import (rows[] / csv / xlsxBase64, dryRun), but writes every row through better-auth so the accounts are login-capable:

  • passwordPolicy: 'none' (default) — identity only: accounts are created without a credential record. Users first sign in through a channel (phone OTP, magic link, or a password-reset link) and the Console detects the missing password (hasLocalPassword()) and offers set-initial-password.
  • passwordPolicy: 'invite'none plus an invitation per created account: rows need a reachable identity — a real email (set-your-password email; requires an email service) or, for phone-only rows, a wired SMS service (invitation SMS + phone-OTP first sign-in).
  • passwordPolicy: 'temporary' — the no-infrastructure fallback (no email, no SMS): per-row generated temporary passwords, returned once in the response, with mustChangePassword enforced.
  • mode: 'insert' | 'upsert' with matchBy: 'email' | 'phone'. Upsert updates only touch profile fields (name, phone_number, role) — a re-imported file can never modify an existing user's email or reset their password.
  • Synchronous only, at most 500 rows per request (password hashing is CPU-bound); split larger files into batches. Imports are not undoable.

In the Console, platform admins reach this from the Users list → Import toolbar button (shown when the admin plugin is enabled). The import wizard uploads a CSV/Excel file, maps columns, previews with a dry-run, and — for the temporary policy — reveals the generated passwords once with a download, splitting files above 500 rows into batches automatically.


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/request-password-reset - 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

  • POST /api/v1/auth/sign-in/social - Start OAuth flow (body { provider, callbackURL }; returns a redirect URL)
  • GET /api/v1/auth/callback/[provider] - OAuth callback handler

Two-Factor Authentication

  • POST /api/v1/auth/two-factor/enable - Start 2FA enrollment for the current user
  • POST /api/v1/auth/two-factor/verify-totp - Verify a TOTP code
  • POST /api/v1/auth/magic-link/send - Send magic link email
  • GET /api/v1/auth/magic-link/verify - Verify magic link

Phone Number (requires plugins.phoneNumber)

  • POST /api/v1/auth/sign-in/phone-number - Sign in with phone + password
  • POST /api/v1/auth/phone-number/send-otp - Send a sign-in/verification OTP (requires an SMS service; rate-limited)
  • POST /api/v1/auth/phone-number/verify - Verify an OTP
  • POST /api/v1/auth/phone-number/request-password-reset - Request an OTP-based password reset (requires an SMS service)
  • POST /api/v1/auth/phone-number/reset-password - Complete an OTP-based password reset

Admin User Management (requires plugins.admin; platform-admin gated)

  • POST /api/v1/auth/admin/create-user - Create a login-capable account (optional one-time temporary password + forced change)
  • POST /api/v1/auth/admin/set-user-password - Set/reset a user's password (also provisions a credential for SSO-onboarded users)
  • POST /api/v1/auth/admin/import-users - Bulk import users (CSV/JSON/XLSX; none (default) / invite / temporary password policy; ≤500 rows, dry-run supported)
  • POST /api/v1/auth/admin/unlock-user - Clear a brute-force lockout early

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


Best Practices

Security

  1. Use Strong Secrets: Generate a strong random secret for OS_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)
    OS_AUTH_SECRET=your-secret-here
    GOOGLE_CLIENT_ID=your-client-id
  4. Session Expiry: Configure appropriate session expiry times

    new AuthPlugin({
      secret: process.env.OS_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 only after your UI supports the full TOTP challenge and recovery flow
  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.OS_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 Console/MSW builds where better-auth is unavailable).

Next Steps


Examples

Complete working examples are available in the repository:

On this page

Authentication GuideTable of ContentsOverviewKey FeaturesArchitectureCLI device flowInstallationBasic Setup1. Environment Variables2. Plugin Configuration3. ObjectQL Data PersistenceAuthentication MethodsEmail/Password AuthenticationSign UpSign InSign OutGet Current SessionPassword ManagementRequest Password ResetReset Password with TokenEmail VerificationSend Verification EmailVerify EmailPhone-Number Authentication (SMS OTP)Phone + password sign-inOTP sign-in and self-service reset (requires SMS delivery)SMS text customisation & localisationOAuth ProvidersConfigurationZero-config Google (env only)OAuth Flow1. Initiate OAuth Login2. Handle CallbackEnterprise Authentication Hardening (ADR-0069)Password policyAnti-abuse (lockout & rate-limiting)Multi-factor (enforced MFA)SessionsNetworkAdvanced FeaturesTwo-Factor Authentication (2FA)Enable 2FA for UserVerify 2FA CodePasskeys (WebAuthn)Magic LinksSend Magic LinkVerify Magic LinkPhone-Number Sign-InSign In with Phone + PasswordOTP Flows (Require an SMS Service)Placeholder Emails for Phone-Only AccountsOrganizations (Multi-Tenant)Admin User ManagementCreate a User DirectlySet / Reset a PasswordBulk Import UsersClient IntegrationUsing @objectstack/clientDirect API CallsAPI ReferenceAuthentication EndpointsEmail/PasswordSessionPassword ManagementEmail VerificationOAuthTwo-Factor AuthenticationMagic LinksPhone Number (requires plugins.phoneNumber)Admin User Management (requires plugins.admin; platform-admin gated)Best PracticesSecurityUser ExperienceError HandlingObjectStack Field NamingMSW/Mock ModeHow It WorksMinimal Configuration for Mock ModeMock Fallback EndpointsNext StepsExamples