| title | Authentication Guide |
|---|---|
| description | Complete guide to implementing authentication in ObjectStack using plugin-auth with Better-Auth |
Complete guide to implementing authentication in ObjectStack applications using the @objectstack/plugin-auth package powered by Better-Auth.
- Overview
- Installation
- Basic Setup
- Authentication Methods
- OAuth Providers
- Advanced Features
- Client Integration
- API Reference
- Best Practices
- MSW/Mock Mode
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.
- ✅ Email/Password Authentication - Traditional username/password login
- ✅ OAuth Providers - Google, GitHub, and more
- ✅ Session Management - Automatic session handling with configurable expiry
- ✅ Password Reset - Email-based password reset flow
- ✅ Email Verification - Email verification workflow
- ⚙️ 2FA backend - better-auth two-factor plugin wiring is available for custom UIs
- ✅ Magic Links - Passwordless authentication
- ✅ Organizations - Multi-tenant support
- ✅ ObjectQL Integration - Native ObjectStack data persistence (no ORM required)
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()
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 secretInstall the plugin in your ObjectStack project:
pnpm add @objectstack/plugin-authThe plugin requires Better-Auth as a peer dependency, which will be automatically installed.
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).
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.
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.
// 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);const session = await client.auth.login({
type: 'email',
email: 'user@example.com',
password: 'securePassword123'
});
console.log('Logged in:', session.data.user);await client.auth.logout();const session = await client.auth.me();
console.log('Current user:', session.data.user);// 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'
})
});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'
})
});const response = await fetch('http://localhost:3000/api/v1/auth/send-verification-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
}
});// User clicks link in email with token
const response = await fetch(`http://localhost:3000/api/v1/auth/verify-email?token=${token}`);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.
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.
// Recommended: use the client SDK, which posts to /sign-in/social and redirects.
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.origin + '/login',
}),
});
const data = await response.json();
window.location.href = data.url ?? data.data.url;Better-Auth automatically handles the OAuth callback at /api/v1/auth/callback/google and redirects the user back to your application with a session.
ObjectStack wires the better-auth two-factor plugin at the backend layer, but the open-source Console login UI does not yet ship the complete TOTP challenge flow. Do not expose 2FA as an end-user feature until your UI handles:
- 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
}
})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.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
})
});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.
Enable passwordless magic link authentication:
new AuthPlugin({
secret: process.env.OS_AUTH_SECRET,
baseUrl: 'http://localhost:3000',
plugins: {
magicLink: true, // Enable magic links
}
})const response = await fetch('http://localhost:3000/api/v1/auth/magic-link/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'user@example.com'
})
});// User clicks link in email
const response = await fetch(`http://localhost:3000/api/v1/auth/magic-link/verify?token=${token}`);Enable organization/team support:
new AuthPlugin({
secret: process.env.OS_AUTH_SECRET,
baseUrl: 'http://localhost:3000',
plugins: {
organization: true, // Enable organizations
}
})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');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}`
}
});All endpoints are available under /api/v1/auth/*:
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
GET /api/v1/auth/get-session- Get current user session
POST /api/v1/auth/request-password-reset- Request password reset emailPOST /api/v1/auth/reset-password- Reset password with token
POST /api/v1/auth/send-verification-email- Send verification emailGET /api/v1/auth/verify-email- Verify email with token
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
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
POST /api/v1/auth/magic-link/send- Send magic link emailGET /api/v1/auth/magic-link/verify- Verify magic link
For complete API documentation, see the Better-Auth API Reference.
-
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 } })
- 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
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);
}
}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).
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.
- 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.
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).
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).
- 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
Complete working examples are available in the repository: