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.
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 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-authBetter-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-onlyImportant: 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';
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
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.
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 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}},
{{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
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-in attempts. Counts both stages: wrong passwords and wrong two-factor codes. |
lockout_duration_minutes | How 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_seconds | Per-IP request throttle on auth endpoints. |
The two stages keep separate counters — sys_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)
| 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. Since nothing is behind it,GET /api/v1/auth/configstopped reporting afeatures.passkeysflag in protocol 17 (#7481) — there is no capability for a client to detect. 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
}
})Server-side only, for now. The endpoints below work whenever
plugins.magicLinkis on, but no shipped login UI drives them — soGET /api/v1/auth/configstopped advertising afeatures.magicLinkflag 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).
Send Magic Link
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'
})
});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
}
})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 — unlikeinvite— it never fails the request for missing infrastructure (undeliverable rows just degrade). The per-row outcome is reported asrows[].delivery(email/sms/temporary), with a batch breakdown onsummary.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, withmustChangePasswordenforced.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'withmatchBy: '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 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/sign-in/magic-link- 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;auto(default, per-row invite-or-temporary) /invite/temporary/nonepassword 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', 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 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. The dispatcher's/authdomain then bridges straight to that service'shandleRequest()— it has no mock fallback of its own, and answers501when theauthslot 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 authenticationIt 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
- 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: