Auth Package
Authentication integration with Better-auth using the OpenSaaS plugin system.
Installation
pnpm add @opensaas/stack-auth
Quick Start
Add the auth plugin to your OpenSaaS config:
// opensaas.config.ts
import { config, list, text, relationship } from '@opensaas/stack-core'
import { authPlugin } from '@opensaas/stack-auth'
export default config({
plugins: [
authPlugin({
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
},
sessionFields: ['userId', 'email', 'name'],
}),
],
db: {
provider: 'sqlite',
url: 'file:./dev.db',
},
lists: {
Post: list({
fields: {
title: text(),
author: relationship({ ref: 'User.posts' }),
},
access: {
operation: {
create: ({ session }) => !!session,
update: ({ session, item }) => session?.userId === item.authorId,
},
},
}),
},
})
Then set up the server and client:
// lib/auth.ts
import { createAuth } from '@opensaas/stack-auth/server'
import config from '../opensaas.config'
import { rawOpensaasContext } from '@/.opensaas/context'
export const auth = createAuth(config, rawOpensaasContext)
export const GET = auth.handler
export const POST = auth.handler
// lib/auth-client.ts
'use client'
import { createClient } from '@opensaas/stack-auth/client'
export const authClient = createClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
})
Configuration Options
The authPlugin() function accepts the following configuration options:
emailAndPassword
Configure email and password authentication:
authPlugin({
emailAndPassword: {
enabled: true,
minPasswordLength: 8, // default: 8
requireConfirmation: true, // default: true
},
})
sendResetPassword is forwarded straight through to better-auth's own emailAndPassword.sendResetPassword — no stack wrapping. It receives exactly what better-auth passes (user, url, token), so you build the subject line and body yourself:
authPlugin({
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url }) => {
await emailService.send({
to: user.email,
subject: 'Reset your password',
html: `<a href="${url}">Reset your password</a>`,
})
},
},
})
If not provided, reset emails are logged to the console in development.
emailVerification
Configure email verification for new sign-ups:
authPlugin({
emailVerification: {
enabled: true,
sendOnSignUp: true, // default: true
tokenExpiration: 86400, // default: 86400 (24 hours)
},
})
sendVerificationEmail is forwarded straight through to better-auth's own emailVerification.sendVerificationEmail — no stack wrapping. It receives exactly what better-auth passes (user, url, token):
authPlugin({
emailVerification: {
enabled: true,
sendVerificationEmail: async ({ user, url }) => {
await emailService.send({
to: user.email,
subject: 'Verify your email',
html: `<a href="${url}">Verify your email</a>`,
})
},
},
})
If not provided, verification emails are logged to the console in development.
passwordReset
Configure password reset functionality:
authPlugin({
passwordReset: {
enabled: true,
tokenExpiration: 3600, // default: 3600 (1 hour)
},
})
socialProviders
Configure OAuth/social authentication providers:
authPlugin({
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
enabled: true,
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
enabled: true,
},
discord: {
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
},
},
})
Supported providers: github, google, discord, twitter
session
Configure session behavior:
authPlugin({
session: {
expiresIn: 604800, // default: 604800 (7 days)
updateAge: 86400, // default: 86400 (1 day) - seconds between session refreshes; set `false` to disable
},
})
sessionFields
Define which fields are available in the session object passed to access control functions:
authPlugin({
sessionFields: ['userId', 'email', 'name', 'role'],
})
These fields will be automatically typed and available in your access control functions:
access: {
operation: {
update: ({ session }) => {
// session is typed as { userId: string; email: string; name: string; role: string } | null
return session?.role === 'admin'
},
},
}
sessionFields describes a flattened projection, not the session's own shape. Each name is resolved off the resolved better-auth session (whatever auth.api.getSession() returns) against a fixed precedence, so a collision between sources is predictable:
userIdis special-cased to the authenticated user'sid— the documented default.- Every other name resolves against the first hit in: a top-level key on the resolved session object, then the
userobject, then thesessionsub-object. This is what makes a session-only field (e.g. the admin plugin'simpersonatedBy) reachable, not just fields on the user.
A name that can't be resolved is omitted from the session and logs a warning (once per field, per process) naming what was checked, instead of silently surfacing later as an access-control function reading undefined.
A customSession better-auth plugin fully replaces the resolved session and can nest its fields anywhere — e.g. under its own custom key. When that happens, sessionFields and the actual resolved shape describe different things, and reconciling them (renaming, flattening a nested value) is the application's job, not something sessionFields does automatically.
The scaffolded getSession() (lib/auth.ts) calls the exported getSessionFromAuth() helper (@opensaas/stack-auth/server) with the config's resolved sessionFields, read at runtime — changing sessionFields takes effect without regenerating lib/auth.ts. getSessionFromAuth() returns null only when there is genuinely no session; a resolved session with no user key (a customSession plugin that dropped it) is still a session and still gets projected. Errors from the underlying session lookup propagate rather than becoming null.
extendUserList
Add custom fields, access control, or hooks to the auto-generated User list:
authPlugin({
extendUserList: {
fields: {
role: select({
options: [
{ label: 'Admin', value: 'admin' },
{ label: 'User', value: 'user' },
],
defaultValue: 'user',
}),
posts: relationship({
ref: 'Post.author',
many: true,
}),
},
access: {
operation: {
delete: ({ session }) => session?.role === 'admin',
},
},
hooks: {
afterOperation: async ({ operation, item }) => {
if (operation === 'create') {
console.log('New user created:', item.email)
}
},
},
},
})
betterAuthPlugins
Add Better Auth plugins for additional functionality:
import { authPlugin } from '@opensaas/stack-auth'
import { mcp } from '@opensaas/stack-auth/plugins'
import { jwt } from 'better-auth/plugins'
authPlugin({
betterAuthPlugins: [
// better-auth 1.7's mcp() is built on the OAuth Provider, which issues
// JWT-based access tokens and requires better-auth's own jwt() plugin
// registered alongside it.
jwt(),
mcp({
loginPage: '/sign-in',
// The page where a user approves/denies an MCP client's requested
// scopes — also required since better-auth 1.7's MCP plugin.
consentPage: '/consent',
// Canonical protected-resource identifier (RFC 8707/9728) — required
// since better-auth 1.7's MCP plugin. Must match `mcp.basePath` below.
resource: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/mcp`,
}),
// Add other Better Auth plugins here
],
})
The auth plugin automatically converts Better Auth plugin schemas to OpenSaaS lists.
credentialFields
Mark additional better-auth model fields as credentials, so they ship field-level read-denied alongside the stack's own seeded set. See Credential fields are read-denied below for the full contract and the seeded set.
authPlugin({
betterAuthPlugins: [passkey()],
credentialFields: { passkey: ['publicKey'] },
})
betterAuthOptions
Escape hatch for any better-auth option the stack doesn't model as its own config field. Deep-merged into the options createAuth() builds, applied last — a plain-object value at a given key merges recursively with what the stack already set there (so a nested addition like session.cookieCache adds alongside the stack's own session.expiresIn/updateAge rather than replacing them), and on a genuine key collision betterAuthOptions wins. Arrays and any other value type replace the stack's value outright.
authPlugin({
betterAuthOptions: {
// Sync a domain user row for every better-auth user
databaseHooks: { user: { create: { after: syncDomainUser } } },
// 5-minute session cookie cache
session: { cookieCache: { enabled: true, maxAge: 300 } },
// Keep PII out of the verification table
verification: { storeIdentifier: 'hashed' },
// Derive the base URL instead of relying on env vars
baseURL: process.env.BETTER_AUTH_URL,
},
})
database and plugins are rejected — they're already the dedicated seams (the stack's db config, and betterAuthPlugins above) and accepting them here would create two unranked ways to set the same thing. So is additionalFields under user/session/account/verification: it has schema consequences (new columns) that a passthrough can't also apply to the generated Prisma schema, so add fields to the derived list instead — extendUserList for the user model, or declare the list yourself in your own lists config for the others.
The same options object is available standalone via buildBetterAuthOptions() — see Escape hatch: hand-wiring betterAuth() below.
rateLimit
Controls rate limiting for authentication endpoints:
authPlugin({
rateLimit: {
enabled: true,
window: 60, // seconds
max: 100, // requests per window
},
})
storage mirrors better-auth's own rateLimit.storage option ('memory' | 'database' | 'secondary-storage', default 'memory'). Setting it to 'database' is what generates the fifth RateLimit list (see Auto-Generated Lists below) — the persisted limiter needs a table, and this is the only way to get one:
authPlugin({
rateLimit: {
enabled: true,
storage: 'database',
},
})
Derivation keys off storage alone, not enabled — { enabled: false, storage: 'database' } still produces the RateLimit list, since better-auth still expects the table regardless of whether the limiter is currently active (enabled is routinely environment-driven, and tying the generated schema to it would make dev and prod schemas differ).
rateLimit also carries the same adoption knobs as the other four models — modelName, fields, tableName, schema, indexes — so an app with an existing database-backed limiter table can adopt it rather than being forced into a new one:
authPlugin({
rateLimit: {
enabled: true,
storage: 'database',
modelName: 'AuthRateLimit',
fields: { key: 'limit_key' },
},
})
Setting storage via the betterAuthOptions.rateLimit passthrough is rejected — it has schema consequences (deriving the RateLimit list) a passthrough can't also apply to the generated Prisma schema. Other betterAuthOptions.rateLimit keys (customRules, customStorage) still pass through and merge with enabled/window/max as usual.
Every per-model block — user / session / account / verification / rateLimit — also accepts indexes, using the same entry shape as a list's own db.indexes: app-authored model-level @@unique/@@index constraints, naming this model's own field keys. An entry covering a column the stack already derives an index for (e.g. User.email) suppresses that derived index and emits only the app's entry — see Adopting a live constraint name or adding your own index for the full explanation and examples.
Auto-Generated Lists
The auth plugin automatically generates the following lists:
User
id(String, auto-generated)email(String, unique, required)emailVerified(Boolean)name(String, optional)image(String, optional)createdAt(DateTime, auto)updatedAt(DateTime, auto)- Custom fields from
extendUserList
Session
id(String, auto-generated)userId(String, foreign key to User)expiresAt(DateTime)token(String, unique — read-denied, see below)ipAddress(String, optional)userAgent(String, optional)createdAt(DateTime, auto)updatedAt(DateTime, auto)
Account
Stores OAuth provider information and password hashes:
id(String, auto-generated)userId(String, foreign key to User)accountId(String, provider-specific user ID)providerId(String, e.g., 'github', 'google')accessToken(String, optional — read-denied, see below)refreshToken(String, optional — read-denied, see below)expiresAt(DateTime, optional)password(String, optional, hashed — read-denied, see below)createdAt(DateTime, auto)updatedAt(DateTime, auto)
Verification
Stores email verification and password reset tokens:
id(String, auto-generated)identifier(String, email address)value(String, token — read-denied, see below)expiresAt(DateTime)createdAt(DateTime, auto)updatedAt(DateTime, auto)
Credential fields are read-denied (ADR-0036)
Session.token, Verification.value, and Account.password/accessToken/refreshToken/idToken hold live, presentable credentials — reading one is equivalent to holding it (session hijack, account takeover, replaying an OAuth token). The plugin sets a field-level read deny on each of them when it derives the list, so granting operation-level access to a list (e.g. access: { session: { operation: { query: () => true } } } for a "your active sessions" screen) does not also expose the token column — the field is silently stripped from a returned row, the same as any other field-level read denial, and the rest of the row is returned normally.
The same deny covers better-auth plugin table credential fields for the plugins the stack has first-class support for — plugin tables derive through the identical field-derivation pass as the base models (ADR-0034):
| Model (better-auth key) | Field(s) | Plugin |
|---|---|---|
oauthClient | clientSecret | mcp / oauth-provider |
oauthAccessToken | token | mcp / oauth-provider |
oauthRefreshToken | token | mcp / oauth-provider |
twoFactor | secret, backupCodes | twoFactor() |
For any other plugin, mark a field as a credential yourself via credentialFields — keyed by better-auth's own model key (not the derived list key) and naming better-auth's own field keys (not mapped column names). It is strictly additive: it can mark further fields, but can never unmark one of the fields above.
authPlugin({
betterAuthPlugins: [passkey()],
credentialFields: {
passkey: ['publicKey'],
},
})
An entry naming a field that doesn't exist on a model your app actually derives (the plugin is registered) throws at config time, naming the model and field; an entry for a model your app doesn't derive at all (the plugin isn't registered) is a silent no-op.
Naming a denied field in findMany's (or count's) where/orderBy is different: that's rejected up front with a ValidationError rather than silently stripped, the same as any other field-level read deny. A findUnique lookup is not — its where only unique-selects the row, so context.db.session.findUnique({ where: { token } }) still finds and returns the session, just with token stripped from the result like any other read.
sudo() bypasses both — it is the supported path for an application with a genuine need:
// An admin tool that must inspect a live session token, filter sessions BY
// token, or an auth implementation verifying a password hash — all bypass
// the deny deliberately.
const session = await context.sudo().db.session.findUnique({ where: { token } })
session.token // present
The deny is keyed to better-auth's own model/field, not the app's list key or column name, so it still applies after a modelName remap (session: { modelName: 'AuthSession' }) or a column override (session: { fields: { token: 'session_token' } } }). Every other Auth list field — identifiers, timestamps, ipAddress/userAgent, providerId/accountId, every User field — stays open to whatever operation-level access you grant.
Better-auth's own sign-in/sign-up/session-refresh/password-reset flows are unaffected: they write and read through the raw Prisma adapter, never through the access-controlled context.db these denies gate.
RateLimit
Only present when rateLimit.storage: 'database' is set — mirrors better-auth's own rate-limit table exactly:
id(String, auto-generated)key(String, unique, required — load-bearing: the limiter races concurrent requests into a create and relies on the unique-constraint violation to serialise them)count(Int, required)lastRequest(BigInt, required — a millisecond epoch)
No createdAt/updatedAt (better-auth's own table has neither), and none of the three columns carries a database default — the limiter supplies lastRequest explicitly on every write. Like the other four lists, it ships closed by default (ADR-0013); grant access via authPlugin({ access: { rateLimit: { ... } } }).
Server Setup
Create auth handlers for your API routes:
// lib/auth.ts
import { createAuth } from '@opensaas/stack-auth/server'
import config from '../opensaas.config'
import { rawOpensaasContext } from '@/.opensaas/context'
export const auth = createAuth(config, rawOpensaasContext)
// Export handlers for Next.js API routes
export const GET = auth.handler
export const POST = auth.handler
Then create the API route:
// app/api/auth/[...all]/route.ts
export { GET, POST } from '@/lib/auth'
Escape hatch: hand-wiring betterAuth()
createAuth() covers the common case. If you need to construct betterAuth() yourself — e.g. a third-party contract that requires a resolved instance rather than createAuth()'s lazy proxy — buildBetterAuthOptions() gives you the exact same options object createAuth() passes to betterAuth(), so your hand-wired instance derives from the stack config instead of duplicating it:
// lib/auth.ts
import { betterAuth } from 'better-auth'
import { buildBetterAuthOptions } from '@opensaas/stack-auth/server'
import config from '../opensaas.config'
import { rawOpensaasContext } from '@/.opensaas/context'
export const auth = betterAuth({
...(await buildBetterAuthOptions(config, rawOpensaasContext)),
// Local additions on top of the stack-derived options
databaseHooks: { user: { create: { after: syncDomainUser } } },
})
This keeps the auth plugin authoritative for everything it models (providers, session expiry, password policy, the plugin array) while your additions stay an explicit, reviewable diff. It also gives an incremental migration path onto createAuth(): adopt the builder first, then move options into betterAuthOptions as the stack grows knobs for them.
Typed auth.api.* reads: pass your plugin tuple
Called with just (config, context), both createAuth() and buildBetterAuthOptions() return the widened BetterAuthOptions / Auth<BetterAuthOptions> types. better-auth infers plugin endpoints and a customSession()'s replaced session shape from the literal type of the options object, so constructing from a widened type erases them — a plugin like emailOTP() loses auth.api.signInEmailOTP, and auth.api.getSession() falls back to better-auth's default { user, session } instead of your customSession() callback's return type.
If your app reads auth.api.* in typed code and uses either of those, pass your betterAuthPlugins array as a third argument — the exact same array already passed to authPlugin({ betterAuthPlugins }) — to either function:
// auth-plugins.ts
import { emailOTP } from 'better-auth/plugins'
export const appBetterAuthPlugins = [emailOTP({ sendVerificationOTP })]
// opensaas.config.ts
import { authPlugin } from '@opensaas/stack-auth'
import { appBetterAuthPlugins } from './auth-plugins'
export default config({
plugins: [authPlugin({ betterAuthPlugins: appBetterAuthPlugins })],
// ...
})
// lib/auth.ts
import { betterAuth } from 'better-auth'
import { buildBetterAuthOptions } from '@opensaas/stack-auth/server'
import config from '../opensaas.config'
import { rawOpensaasContext } from '@/.opensaas/context'
import { appBetterAuthPlugins } from '../auth-plugins'
export const auth = betterAuth({
...(await buildBetterAuthOptions(config, rawOpensaasContext, appBetterAuthPlugins)),
databaseHooks: { user: { create: { after: syncDomainUser } } },
})
// auth.api.signInEmailOTP is now typed, and auth.api.getSession() returns
// your customSession() shape if you have one.
The same third argument works on createAuth() — createAuth(config, rawOpensaasContext, appBetterAuthPlugins). Either way, the supplied array is for typing only: the plugin array actually used at runtime is always the one resolved from authPlugin({ betterAuthPlugins }), with exactly one nextCookies() appended last. Passing an array that isn't the same plugin instances in the same order throws, naming the mismatch, so the two can't silently drift apart.
Which entry point to reach for: createAuth()'s lazy Proxy does not behave identically to a real Auth instance for every property — every access, including a non-function property, is surfaced through an async wrapper (so auth.options, for example, reads back as a Promise rather than the plain object a real instance returns synchronously). If your app reads auth.api.* in typed code, reach for buildBetterAuthOptions() plus betterAuth() — it constructs a real instance and does not have this gap.
Client Setup
Create a client for authentication in your components:
// lib/auth-client.ts
'use client'
import { createClient } from '@opensaas/stack-auth/client'
export const authClient = createClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
})
export const {
signIn,
signUp,
signOut,
useSession,
// ... other auth methods
} = authClient
UI Components
The auth package includes pre-built UI components:
SignInForm
import { SignInForm } from '@opensaas/stack-auth/ui'
import { authClient } from '@/lib/auth-client'
export default function SignInPage() {
return (
<SignInForm
authClient={authClient}
redirectTo="/admin"
showSocialProviders={true}
/>
)
}
SignUpForm
import { SignUpForm } from '@opensaas/stack-auth/ui'
import { authClient } from '@/lib/auth-client'
export default function SignUpPage() {
return (
<SignUpForm
authClient={authClient}
redirectTo="/admin"
showSocialProviders={true}
/>
)
}
useSession Hook
'use client'
import { useSession } from '@/lib/auth-client'
export function UserProfile() {
const { data: session, isPending } = useSession()
if (isPending) return <div>Loading...</div>
if (!session) return <div>Not signed in</div>
return <div>Welcome, {session.user.name}!</div>
}
Access Control Integration
The session is automatically available in all access control functions:
lists: {
Post: list({
fields: {
title: text(),
content: text(),
author: relationship({ ref: 'User.posts' }),
},
access: {
operation: {
// Only authenticated users can create posts
create: ({ session }) => !!session,
// Only the author can update their posts
update: ({ session, item }) => {
return session?.userId === item.authorId
},
// Everyone can read published posts
query: () => true,
},
filter: {
// Users can only see their own drafts
query: ({ session }) => {
if (!session) {
return { status: { equals: 'published' } }
}
return {
OR: [
{ status: { equals: 'published' } },
{ authorId: { equals: session.userId } },
],
}
},
},
},
}),
}
MCP Integration
To enable Model Context Protocol support with Better Auth authentication:
import { authPlugin } from '@opensaas/stack-auth'
import { mcp } from '@opensaas/stack-auth/plugins'
import { jwt } from 'better-auth/plugins'
export default config({
plugins: [
authPlugin({
emailAndPassword: { enabled: true },
betterAuthPlugins: [
// better-auth 1.7's mcp() requires the jwt() plugin alongside it.
jwt(),
mcp({
loginPage: '/sign-in',
consentPage: '/consent',
resource: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/mcp`,
}),
],
}),
],
mcp: {
enabled: true,
auth: {
type: 'better-auth',
loginPage: '/sign-in',
},
},
lists: {
// Your lists
},
})
The MCP plugin automatically converts its schema to OpenSaaS lists and enables OAuth authentication for AI assistants.
See MCP Integration Guide for more details.
Examples
- Basic Authentication - Email/password and OAuth
- MCP Integration - Better Auth MCP plugin
Further Reading
- Authentication Guide - Comprehensive authentication guide
- Access Control Guide - Using sessions in access control
- Better Auth Documentation - Official Better Auth docs