Auth Package

Authentication integration with Better-auth using the OpenSaaS plugin system.

Installation

bash
pnpm add @opensaas/stack-auth

Quick Start

Add the auth plugin to your OpenSaaS config:

typescript
// opensaas.config.ts
import { config, list } from '@opensaas/stack-core'
import { text, relationship } from '@opensaas/stack-core/fields'
import { authPlugin } from '@opensaas/stack-auth'

export default config({
  plugins: [
    authPlugin({
      emailAndPassword: {
        enabled: true,
        minPasswordLength: 8,
      },
      sessionFields: ['userId', 'email', 'name'],
      // The other end of `Post.author`. Without it `pnpm generate` refuses the
      // relationship: the two ends must name each other.
      extendUserList: {
        fields: {
          posts: relationship({ ref: 'Post.author', many: true }),
        },
      },
    }),
  ],
  db: { provider: 'postgresql' },
  lists: {
    Post: list({
      fields: {
        title: text(),
        author: relationship({ ref: 'User.posts' }),
      },
      access: {
        operation: {
          create: ({ session }) => !!session,
          update: ({ session }) => (session ? { authorId: { equals: session.userId } } : false),
        },
      },
    }),
  },
})

postgresql is the only provider, and the connection string is resolved from the environment rather than declared here — see the Config API for the complete db key list.

Then set up the server and client:

typescript
// 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
typescript
// 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:

typescript
authPlugin({
  emailAndPassword: {
    enabled: true,
    minPasswordLength: 8, // default: 8
  },
})

requireConfirmation here has no effect. It is on the type, but createAuth() never reads it — there is no better-auth option to forward it to — and warns when it is set to anything but the default. The real knob is the form's own requirePasswordConfirmation prop: <SignUpForm requirePasswordConfirmation={false} /> / <ResetPasswordForm requirePasswordConfirmation={false} />, both defaulting to 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:

typescript
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:

typescript
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):

typescript
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:

typescript
authPlugin({
  passwordReset: {
    enabled: true,
    tokenExpiration: 3600, // default: 3600 (1 hour)
  },
})

socialProviders

Configure OAuth/social authentication providers:

typescript
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:

typescript
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:

typescript
authPlugin({
  sessionFields: ['userId', 'email', 'name', 'role'],
})

These fields will be automatically typed and available in your access control functions:

typescript
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:

  1. userId is special-cased to the authenticated user's id — the documented default.
  2. Every other name resolves against the first hit in: a top-level key on the resolved session object, then the user object, then the session sub-object. This is what makes a session-only field (e.g. the admin plugin's impersonatedBy) reachable, not just fields on the user.

A name better-auth's session does not carry but the user list does — a field added through extendUserList, say — is read from the signed-in user's own row, by id, in one query for all such names together. No query runs when every name comes from better-auth. Values are the stored ones, before any resolveOutput. Naming a field here is a deliberate config choice, like needs: it does not pass through that field's read access, because the session must exist before access can be evaluated. The fill-in stays server-side; better-auth's client session endpoint and useSession do not gain the field. If the user row is gone mid-session, getSessionFromAuth() returns null.

A name that exists nowhere (not in better-auth's session or plugin schemas, and not a scalar field of the user list) throws a configuration error at startup naming the entry and where it was looked for. The check is skipped when a customSession plugin is registered, since that plugin replaces the session shape. If a field is both in extendUserList and already supplied by better-auth (for example role with the admin() plugin) and is listed in sessionFields, startup throws, naming the collision — drop it from extendUserList and let the plugin supply it.

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:

typescript
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 (args) => {
        if (args.operation !== 'create') return
        console.log('New user created:', args.item.email)
      },
    },
  },
})

afterOperation's arguments are a union over the operation: create and update carry item, delete carries originalItem instead. Narrow on args.operation before reaching for a row.

betterAuthPlugins

Add Better Auth plugins for additional functionality. mcp() is imported from @opensaas/stack-auth/plugins rather than better-auth/plugins — better-auth 1.7 split it into the optional @better-auth/mcp peer, and this package re-exports it from there. It is built on the OAuth Provider, which issues JWT-based access tokens, so better-auth's own jwt() must be registered alongside it:

typescript
import { authPlugin } from '@opensaas/stack-auth'
import { mcp } from '@opensaas/stack-auth/plugins'
import { jwt } from 'better-auth/plugins'

authPlugin({
  betterAuthPlugins: [
    jwt(),
    mcp({
      loginPage: '/sign-in',
      consentPage: '/consent',
      resource: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/mcp`,
    }),
  ],
})

consentPage is where a user approves or denies an MCP client's requested scopes; resource is the canonical protected-resource identifier (RFC 8707/9728) and must match the app's mcp.basePath. Both became required with better-auth 1.7's MCP plugin.

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.

typescript
authPlugin({
  betterAuthPlugins: [passkey()],
  credentialFields: { passkey: ['publicKey'] },
})

fieldAccess

Override the field-level access the auth plugin seeds on a derived Auth list field — reopening a seeded credential read-deny is refused, but a seeded write-deny (see Fields are write-denied independent of operation access below) can be reopened per operation. 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), same as credentialFields. Each entry replaces exactly the operations it names — anything it doesn't name keeps its seeded rule.

typescript
authPlugin({
  betterAuthPlugins: [admin()],
  fieldAccess: {
    user: {
      role: { update: ({ session }) => session?.role === 'admin' },
    },
  },
})

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.

typescript
authPlugin({
  betterAuthOptions: {
    databaseHooks: { user: { create: { after: syncDomainUser } } },
    session: { cookieCache: { enabled: true, maxAge: 300 } },
    verification: { storeIdentifier: 'hashed' },
    baseURL: process.env.BETTER_AUTH_URL,
  },
})

Those four, in order: sync a domain user row for every better-auth user; cache the session cookie for five minutes; keep PII out of the verification table; and derive the base URL rather than relying on better-auth's own env lookup.

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:

typescript
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:

typescript
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:

typescript
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', 'credential' for email/password)
  • issuer (String, the identity issuer the account is scoped to)
  • accessToken (String, optional — read-denied, see below)
  • refreshToken (String, optional — read-denied, see below)
  • idToken (String, optional — read-denied, see below)
  • accessTokenExpiresAt (DateTime, optional)
  • refreshTokenExpiresAt (DateTime, optional)
  • scope (String, 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, lookup key such as reset-password:<token> — read-denied)
  • 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.identifier/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
oauthClientclientSecretmcp / oauth-provider
oauthAccessTokentokenmcp / oauth-provider
oauthRefreshTokentokenmcp / oauth-provider
twoFactorsecret, backupCodestwoFactor()

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.

typescript
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 a read's where or orderBy is different: that's rejected up front with a ValidationError rather than silently stripped. The message is the same one an undeclared field gets, deliberately — a refusal that distinguished the two would be an existence oracle (ADR-0031). Writes are unaffected because a write's where is identity-only: it carries id and nothing else, so a credential column can never appear in one.

sudo() bypasses both the field-level deny and the predicate refusal, and is the supported path for an application with a genuine need — an admin tool inspecting a live session token, or an auth implementation verifying a password hash:

typescript
const found = await context
  .sudo()
  .db.Session.where({ token: { equals: token } })
  .first()
if (found !== null) {
  console.log(found.token)
}

sudo() still validates the Where vocabulary; only access is skipped.

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.

Fields are write-denied independent of operation access (ADR-0073)

A whole-row operation-level update rule (update: ({ session, item }) => session?.userId === item.id) says who may write the row — not which columns. User.emailVerified is write-denied unconditionally (better-auth marks it input: false in its own schema), and once you register the admin() plugin, so are User.role, User.banned, User.banReason, User.banExpires and Session.impersonatedBy. These are exactly the fields better-auth's own client-facing API refuses to accept, so a signed-in user's context.db write can't set them either — a whole-row owner-update rule does not get around this:

typescript
authPlugin({
  betterAuthPlugins: [admin()],
  access: {
    user: {
      operation: { update: ({ session, item }) => session?.userId === item.id },
    },
  },
})

// Throws — role is write-denied regardless of the owner rule above.
await context.db.User.update({ where: { id: me }, data: { role: 'admin' } })

Unlike the read-deny above, a denied write throws rather than silently stripping — the same field-level write-access behavior any other denied field gets. sudo() and better-auth's own flows (sign-up, setRole, banUser, email verification) are unaffected, for the same reason as the read-deny: they don't go through this access check. A field that is both a credential and input: false gets both denies, composed rather than one overwriting the other.

Reopen a seeded write-deny with fieldAccess. Unlike credentialFields, an entry replaces exactly the operations it names rather than only adding to a seeded set — but it can never set read on a credential field; that throws at config time, naming the model and field, because the read-deny above must never be reopened.

typescript
authPlugin({
  betterAuthPlugins: [admin()],
  access: {
    user: {
      operation: {
        // Both axes must agree: the operation-level rule must let an admin
        // touch someone else's row, AND fieldAccess must reopen the field.
        update: ({ session, item }) => session?.userId === item.id || session?.role === 'admin',
      },
    },
  },
  fieldAccess: {
    user: {
      role: { update: ({ session }) => session?.role === 'admin' },
    },
  },
})

Validation matches credentialFields: an entry naming a field that doesn't exist on a model your app actually derives throws, naming the model and field; an entry for a model your app doesn't derive at all is a silent no-op; an entry naming an id-referencing relationship field throws (it derives to a relationship(), never a scalar column).

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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// auth-plugins.ts
import { emailOTP } from 'better-auth/plugins'

export const appBetterAuthPlugins = [emailOTP({ sendVerificationOTP })]
typescript
// opensaas.config.ts
import { config } from '@opensaas/stack-core'
import { authPlugin } from '@opensaas/stack-auth'
import { appBetterAuthPlugins } from './auth-plugins'

export default config({
  plugins: [authPlugin({ betterAuthPlugins: appBetterAuthPlugins })],
  db: { provider: 'postgresql' },
  lists: {},
})
typescript
// 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.

Known limits of the Auth adapter

better-auth talks to the database through a stack-authored adapter built over context.unsafe (ADR-0060), deliberately outside the Access Filter — auth's own bookkeeping is not application data. The adapter carries four known limits, and this is the whole set.

The adapter implements no joins. betterAuthOptions.advanced.database.joins is refused at config time rather than accepted and ignored: better-auth's own fallback to separate per-model queries is silent, so accepting the flag would claim a capability nothing provides. Leave it unset.

The adapter implements no createSchema. The Auth lists derive from better-auth's getAuthTables and the stack's own generator emits the contract and the migration, so better-auth's schema CLI — generate and migrate — is unsupported against this adapter. Run pnpm generate and the stack's own migration flow instead.

A raced unique violation surfaces raw. The Unsafe surface is excluded from the stack's error normalisation (ADR-0042), so an error raised on an auth write arrives as the driver's own — a raw SQLSTATE — rather than as a UniqueConstraintViolation with constraintName/list/fields populated. Code that catches around an auth call (two concurrent sign-ups racing the same email, say) must match on the driver's shape, not on the stack's error classes.

There is no issuer-scoped account uniqueness. better-auth declares the account identity key (providerId + accountId) as a table-level @@unique, which the Auth lists do not emit yet (#986). Nothing in the database stops two concurrent sign-ins through the same issuer identity from creating two AuthAccount rows; better-auth's own existence check is all that stands between them. If duplicate accounts would corrupt your linking logic, guard it in your own code rather than relying on the schema.

Client Setup

Create a client for reading the session in client components. The pre-built forms do not use it — they submit through app-owned server actions (see UI Components), so the client is only needed for useSession and an explicit signOut:

typescript
// 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. None of them talks to the auth API from the browser: each form takes an auth action — an app-owned 'use server' function that calls auth.api.* on your own auth instance — as a prop, one prop per concern (ADR-0020). The package exports the contract types those actions satisfy; your app owns the implementations, conventionally in lib/actions/auth.ts.

Auth actions

AuthActionResult is { success: true } | { success: false; error: string }, and the form renders the error message. Email actions resolve to that result and let the form handle redirectTo; signInSocialAction has to leave the app, so it performs a server-side redirect() to the provider and resolves to void.

typescript
// lib/actions/auth.ts
'use server'

import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import type {
  AuthActionResult,
  SignInInput,
  SignUpInput,
  RequestPasswordResetInput,
  ResetPasswordInput,
} from '@opensaas/stack-auth/ui'

function errorMessage(err: unknown, fallback: string): string {
  return err instanceof Error && err.message ? err.message : fallback
}

export async function signInAction(input: SignInInput): Promise<AuthActionResult> {
  try {
    await auth.api.signInEmail({
      body: { email: input.email, password: input.password },
      headers: await headers(),
    })
    return { success: true }
  } catch (err) {
    return { success: false, error: errorMessage(err, 'Sign in failed') }
  }
}

export async function signUpAction(input: SignUpInput): Promise<AuthActionResult> {
  try {
    await auth.api.signUpEmail({
      body: { name: input.name, email: input.email, password: input.password },
      headers: await headers(),
    })
    return { success: true }
  } catch (err) {
    return { success: false, error: errorMessage(err, 'Sign up failed') }
  }
}

export async function requestPasswordResetAction(
  input: RequestPasswordResetInput,
): Promise<AuthActionResult> {
  try {
    await auth.api.requestPasswordReset({
      body: { email: input.email, redirectTo: '/reset-password' },
      headers: await headers(),
    })
    return { success: true }
  } catch (err) {
    return { success: false, error: errorMessage(err, 'Failed to send reset email') }
  }
}

export async function resetPasswordAction(input: ResetPasswordInput): Promise<AuthActionResult> {
  try {
    await auth.api.resetPassword({
      body: { newPassword: input.password, token: input.token },
      headers: await headers(),
    })
    return { success: true }
  } catch (err) {
    return { success: false, error: errorMessage(err, 'Failed to reset password') }
  }
}

export async function signInSocialAction(provider: string): Promise<void> {
  const response = await auth.api.signInSocial({
    body: { provider, callbackURL: '/admin' },
    headers: await headers(),
  })
  if (response && 'url' in response && response.url) {
    redirect(response.url)
  }
}

createAuth() appends better-auth's nextCookies() plugin, so the session cookie these actions set is written into Next's cookies() without any extra step.

SignInForm

signInAction is the only required prop. The OAuth buttons render only when signInSocialAction is supplied — showSocialProviders alone does not summon them.

typescript
// app/sign-in/page.tsx
import { SignInForm } from '@opensaas/stack-auth/ui'
import { signInAction, signInSocialAction } from '@/lib/actions/auth'

export default function SignInPage() {
  return (
    <SignInForm
      signInAction={signInAction}
      signInSocialAction={signInSocialAction}
      redirectTo="/admin"
      showSocialProviders={true}
    />
  )
}

SignUpForm

typescript
// app/sign-up/page.tsx
import { SignUpForm } from '@opensaas/stack-auth/ui'
import { signUpAction, signInSocialAction } from '@/lib/actions/auth'

export default function SignUpPage() {
  return (
    <SignUpForm
      signUpAction={signUpAction}
      signInSocialAction={signInSocialAction}
      redirectTo="/admin"
      showSocialProviders={true}
    />
  )
}

ForgotPasswordForm and ResetPasswordForm

ForgotPasswordForm requests the reset email. ResetPasswordForm completes the reset and additionally takes the token from the email link — the page reads it from searchParams and passes it in; an empty token renders an "invalid or expired link" state instead of a password form.

typescript
// app/forgot-password/page.tsx
import { ForgotPasswordForm } from '@opensaas/stack-auth/ui'
import { requestPasswordResetAction } from '@/lib/actions/auth'

export default function ForgotPasswordPage() {
  return <ForgotPasswordForm requestPasswordResetAction={requestPasswordResetAction} />
}
typescript
// app/reset-password/page.tsx
import { ResetPasswordForm } from '@opensaas/stack-auth/ui'
import { resetPasswordAction } from '@/lib/actions/auth'

export default async function ResetPasswordPage({
  searchParams,
}: {
  searchParams: Promise<{ token?: string }>
}) {
  const { token } = await searchParams
  return <ResetPasswordForm resetPasswordAction={resetPasswordAction} token={token ?? ''} />
}

useSession Hook

typescript
'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.

A list's access carries exactly one member, operation. There is no separate filter block: an operation.query, operation.update or operation.delete rule scopes rows by returning a Prisma-shaped filter instead of a boolean, and that filter is ANDed into the caller's own where. operation.create is the exception — it takes a boolean result only, and returning a filter from it throws InvalidCreateAccessResultError.

typescript
lists: {
  Post: list({
    fields: {
      title: text(),
      content: text(),
      author: relationship({ ref: 'User.posts' }),
    },
    access: {
      operation: {
        create: ({ session }) => !!session,
        update: ({ session }) => (session ? { authorId: { equals: session.userId } } : false),
        query: ({ session }) =>
          session
            ? {
                OR: [
                  { status: { equals: 'published' } },
                  { authorId: { equals: session.userId } },
                ],
              }
            : { status: { equals: 'published' } },
      },
    },
  }),
}

Note the explicit anonymous branch. A rule written ({ session }) => ({ authorId: { equals: session?.userId } }) is not an open read — an undefined condition is refused with a ValidationError rather than dropped, so the read fails rather than widening.

Field-level access is declared on the field itself, not on the list, and its read/create/update rules return a boolean only — a non-boolean throws InvalidFieldAccessResultError.

MCP Integration

To enable Model Context Protocol support with Better Auth authentication:

mcp() comes from @opensaas/stack-auth/plugins, which re-exports it from the optional @better-auth/mcp peer — better-auth 1.7 moved it out of better-auth/plugins. It is built on the OAuth Provider and issues JWT-based access tokens, so jwt() from better-auth/plugins must be registered alongside it. resource must match the MCP route's canonical URL:

typescript
import { config, list } from '@opensaas/stack-core'
import { text } from '@opensaas/stack-core/fields'
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: [
        jwt(),
        mcp({
          loginPage: '/sign-in',
          consentPage: '/consent',
          resource: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/mcp`,
        }),
      ],
    }),
  ],
  db: { provider: 'postgresql' },
  mcp: {
    enabled: true,
    auth: {
      type: 'better-auth',
      loginPage: '/sign-in',
    },
  },
  lists: {
    Post: list({ fields: { title: text() } }),
  },
})

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

Further Reading