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.

Under --json this command is the CLI's one declared NDJSON exception: it emits the verification-URL record before you authorize and the result record afterwards, one compact JSON document per line, so stdout must be parsed line by line rather than with a single JSON.parse. See the CLI reference.

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

Better-Auth is a direct runtime dependency of the plugin, not a peer dependency, so the command above installs it for you — you never add better-auth yourself.


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

# Optional: what a new user joins (ADR-0093 D1). `auto` (default) binds every
# new user to the default organization; `invite-only` grants membership only
# through an explicit act — creating a workspace, accepting an invitation, an
# admin adding them, or SSO just-in-time provisioning.
# Also configurable in Setup → Authentication → Membership.
OS_AUTH_MEMBERSHIP_POLICY=invite-only

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';
import { createRestApiPlugin } from '@objectstack/rest';

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,
}));

// Data API — serves /api/v1/data/:object with full CRUD behind the gate
// stack. Without it this minimal stack has no data routes: the HTTP server
// plugin is a transport adapter and its legacy built-in data surface is
// deprecated and off by default (#4073). The authenticated fetch further
// down this page reads /api/v1/data/task through this plugin.
await kernel.use(createRestApiPlugin());

// 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.

These objects are managedBy: 'better-auth' (an identity-owned lifecycle bucket): generic user-context CRUD through the /data API is suppressed — a fail-closed identity write guard (ADR-0092/0103) rejects direct writes so password hashing, session validation, and verification flows can't be bypassed. better-auth's own writes run under a system context and pass through. Mutate these records through the sign-in, invitation, and security flows above, never by writing the objects directly.


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}}, {{loginUrl}} (invitation — {{baseUrl}}, the bare origin, is still interpolated for tenant templates written against the older text). 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-in attempts. Counts both stages: wrong passwords and wrong two-factor codes.
lockout_duration_minutesHow long a lockout lasts, at either stage. Admins can clear it early with the Unlock action on the user record, which releases both stages.
rate_limit_max / rate_limit_window_secondsPer-IP request throttle on auth endpoints.

The two stages keep separate counterssys_user.failed_login_count for the password check, sys_two_factor.failed_verification_count for the second factor — so a locked password stage and a locked second factor are independent states. Each counter resets on a success at its own stage, and the admin Unlock Account action clears both at once.

Setting lockout_threshold to 0 disables the password-stage lockout only. Two-factor verification keeps a built-in limit of 10 attempts per 15 minutes, because it is the last check before a session is issued. Two-factor verification additionally caps attempts at 5 per challenge, which is not configurable: a threshold above 5 simply forces the attacker to restart the sign-in flow more often before the account-level lock engages.

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. Since nothing is behind it, GET /api/v1/auth/config stopped reporting a features.passkeys flag in protocol 17 (#7481) — there is no capability for a client to detect. 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
  }
})

Server-side only, for now. The endpoints below work whenever plugins.magicLink is on, but no shipped login UI drives them — so GET /api/v1/auth/config stopped advertising a features.magicLink flag in protocol 17 (#7481): a served flag no client reads told deployers a sign-in option existed that users could never reach. Call the endpoints from your own UI. The flag returns alongside the built-in magic-link login screen (objectui#4179).

const response = await fetch('http://localhost:3000/api/v1/auth/sign-in/magic-link', {
  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
  }
})

Membership tiers are a closed list

sys_member.role is better-auth's own column and the one place ObjectStack keeps that vocabulary (ADR-0090 D3's single boundary exception). It holds exactly four values — owner, admin, delegated_admin, member — and your stack cannot add to them.

That is deliberate (ADR-0108). A membership tier answers what is your standing in this organization, which decides what you can reach: delegated_admin, for instance, is the tier that may reach /organization/invite-member without being an org admin. What you may do with records is a position, and every position assignment carries an audit stamp (granted_by), a validity window, and a scope check that a tier never had.

To invite someone straight into a position, put it in the invitation's placement rather than its membership tier:

POST /api/v1/auth/organization/invite-member
{
  "email": "new.hire@example.com",
  "role": "member",
  "businessUnitId": "bu_field_sales",
  "positions": ["sales_rep"]
}

The tier stays member; the capability rides in positions. Placement is authorized at issuance against the issuer's adminScope, so a delegated admin can use it inside their own subtree — where the tier route was open to org admins only. On acceptance the positions become real sys_user_position rows.

Earlier releases registered every declared position and permission-set name as a valid membership tier, so naming one directly in the invitation was accepted. That path is removed: because every value stored in the tier column is projected into current_user.positions, it granted capability with none of the controls above. Naming a position as an invitation's tier is now refused with ROLE_NOT_FOUND. Use positions as shown.

One limit still bounds invitations as a provisioning path: they never hand out more authority than the issuer has. An issuer below admin grade may invite as plain member only, and no invitation may confer a tier above the issuer's own.

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: 'auto' (default) — decides per row: a row with a deliverable channel (a real email + a wired email service, or a phone + a wired SMS service) is invited (no shared secret leaves the server); a row with no deliverable channel (placeholder email, phone-only without SMS, or an email row when no email service is wired) falls back to a temporary password. This keeps the temporary-password blast radius to only the rows that genuinely can't be reached, and — unlike invite — it never fails the request for missing infrastructure (undeliverable rows just degrade). The per-row outcome is reported as rows[].delivery (email / sms / temporary), with a batch breakdown on summary.delivery.
  • passwordPolicy: 'invite' — force the invite path for every row: a real email gets a set-your-password email (requires an email service), a phone-only row an invitation SMS (requires a wired SMS service + phone-OTP first sign-in). Rows that aren't reachable are failed per-row (never downgraded) — pick this when a temporary-password fallback is unacceptable.
  • passwordPolicy: 'temporary' — force the no-infrastructure path (no email, no SMS) for every row: per-row generated temporary passwords, returned once in the response, with mustChangePassword enforced.
  • passwordPolicy: 'none' — 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.
  • mode: 'insert' | 'upsert' with matchBy: 'email' | 'phone'. Upsert updates only touch profile fields (name, image, 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 any rows that resolve to a temporary password (all of them under temporary, only the unreachable ones under auto) — 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();
// better-auth returns the bearer token at the top level of a direct
// `/sign-in/email` response (the client SDK re-wraps it under `data`).
const token = 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/sign-in/magic-link - 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; auto (default, per-row invite-or-temporary) / invite / temporary / none 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', issuer: 'issuer', providerAccountId: 'account_id', … } },
verification: { modelName: 'sys_verification', fields: { expiresAt: 'expires_at', … } },

better-auth 1.7 identifies an account by (issuer, providerAccountId)providerAccountId is the field formerly called accountId (same account_id column) and issuer names the authority that vouched for it: an OIDC iss for federated logins, or a synthetic local:credential / local:oauth:<providerId> for providers that carry none. Rows written before 1.7 have no issuer, so the auth plugin stamps them once at boot; accounts from a federated IdP that is no longer registered cannot be derived and are reported in the boot log instead of guessed.

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 dispatcher's /auth domain then bridges straight to that service's handleRequest() — it has no mock fallback of its own, and answers 501 when the auth slot is empty (see below).

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).

No auth service? /auth/* answers 501 — it never fabricates a session

The dispatcher used to carry a mock fallback: with no auth service in the slot, sign-up/email, register, sign-in/email, login, get-session and sign-out all answered 200 with a made-up user and a 24-hour mock_token_* session — for any email and any password, which was never read.

That is removed (#4113). An unserved auth slot now answers:

501 Auth service not available — register @objectstack/plugin-auth to enable authentication

It was never an authentication bypass — no session store backed that token, so the identity path still resolved anonymous and anonymous data access was still denied. The problem was that it told the client it had authenticated someone when it had not, while discovery simultaneously reported auth: unavailable and advertised no routes.auth. A capability the runtime does not have must not be advertised by pretending to serve it (ADR-0076 D12, ADR-0115).

A service registered in the slot but not implementing the contract's handleRequest takes the same 501.

If you were relying on the mock for a browser-only or MSW build: mock at the HTTP client layer, or with an MSW handler in your own test setup, which is what the console does. Load AuthPlugin (it needs no HTTP server — see above) and the real service answers instead.

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)Membership tiers are a closed listAdmin 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 ModeNo auth service? /auth/* answers 501 — it never fabricates a sessionNext StepsExamples