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
- Overview
- Installation
- Basic Setup
- Authentication Methods
- OAuth Providers
- Advanced Features
- Client Integration
- API Reference
- Best Practices
- 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_mfawith 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 secretNew accounts can be created with:
os register
os register --email user@example.com --name "Jane Doe" --password secretInstallation
Install the plugin in your ObjectStack project:
pnpm add @objectstack/plugin-authThe 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=trueImportant: Never commit
OS_AUTH_SECRETto 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
authservice. 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'susermodel)sys_session- Active sessions (mapped from better-auth'ssessionmodel)sys_account- OAuth provider accounts (mapped from better-auth'saccountmodel)sys_verification- Email/phone verification tokens (mapped from better-auth'sverificationmodel)
Note: better-auth uses hardcoded model names (
user,session, etc.). The ObjectQL adapter automatically maps these tosys_-prefixed protocol names viaAUTH_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 configThe 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
429with the retry window and never invalidate an already delivered code. Tune viaAuthManagerphoneOtp(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 theauthsettingsrate_limit_max/rate_limit_window_secondstighten all four OTP endpoints. - OTP codes never reach logs — the SMS service logs masked numbers and
statuses only;
request-password-resetalways 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
socialProvidersrecord above. The legacyproviders: [...]array still type-checks but is not wired up at runtime — onlysocialProvidersis forwarded to better-auth, so aprovidersarray 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
| Setting | Effect |
|---|---|
password_reject_breached | Reject 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_classes | Require a minimum number of character classes (upper / lower / digit / symbol). |
password_history_count | Block reuse of the last N passwords. |
password_expiry_days | Force a password change after N days; expired sessions are gated until the password is rotated. |
Anti-abuse (lockout & rate-limiting)
| Setting | Effect |
|---|---|
lockout_threshold | Lock an account after this many consecutive failed sign-ins. |
lockout_duration_minutes | How long a lockout lasts. Admins can clear it early with the Unlock action on the user record. |
rate_limit_max / rate_limit_window_seconds | Per-IP request throttle on auth endpoints. |
Multi-factor (enforced MFA)
| Setting | Effect |
|---|---|
mfa_required | Require MFA org-wide (also settable per organization via require_mfa). |
mfa_grace_period_days | Grace 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
| Setting | Effect |
|---|---|
session_idle_timeout_minutes | Expire a session after inactivity. |
session_absolute_max_hours | Hard cap on total session lifetime regardless of activity. |
max_concurrent_sessions_per_user | Cap simultaneous sessions; the oldest is revoked (expired in place) when the cap is exceeded. |
Network
| Setting | Effect |
|---|---|
allowed_ip_ranges | Restrict 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:
customSessionstamps the session with anauthGateposture, 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
twoFactorRedirectresponse 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. Theplugins: { 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.
Magic Links
Enable passwordless magic link authentication:
new AuthPlugin({
secret: process.env.OS_AUTH_SECRET,
baseUrl: 'http://localhost:3000',
plugins: {
magicLink: true, // Enable magic links
}
})Send Magic Link
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'
})
});Verify Magic Link
// 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'—noneplus 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, withmustChangePasswordenforced.mode: 'insert' | 'upsert'withmatchBy: '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 passwordPOST /api/v1/auth/sign-up/email- Register new userPOST /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 emailPOST /api/v1/auth/reset-password- Reset password with token
Email Verification
POST /api/v1/auth/send-verification-email- Send verification emailGET /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 userPOST /api/v1/auth/two-factor/verify-totp- Verify a TOTP code
Magic Links
POST /api/v1/auth/magic-link/send- Send magic link emailGET /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 + passwordPOST /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 OTPPOST /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/temporarypassword 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
-
Use Strong Secrets: Generate a strong random secret for
OS_AUTH_SECRET(minimum 32 characters)# Generate a secure secret openssl rand -base64 32 -
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', }) -
Environment Variables: Never commit secrets to version control
# .env (not committed) OS_AUTH_SECRET=your-secret-here GOOGLE_CLIENT_ID=your-client-id -
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
- Email Verification: Require email verification for sensitive operations
- Password Requirements: Enforce strong password policies
- 2FA for Admin: Require 2FA only after your UI supports the full TOTP challenge and recovery flow
- 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 fromSystemObjectNameconstants).
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
authservice. TheHttpDispatcherprovides 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:
| Endpoint | Method | Description |
|---|---|---|
sign-up/email | POST | Returns mock user + session |
sign-in/email | POST | Returns mock user + session |
login | POST | Legacy login — returns mock user + session |
register | POST | Alias for sign-up |
get-session | GET | Returns { session: null, user: null } |
sign-out | POST | Returns { 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-authis unavailable).
Next Steps
- See Security Guide for authorization and permissions
- See Client SDK Guide for client-side integration
- See API Reference for complete API documentation
- Visit Better-Auth Documentation for advanced features
Examples
Complete working examples are available in the repository: