Authentication Guide
This guide covers everything you need to implement authentication in your Stack application using Better-auth integration.
Introduction
Stack provides seamless authentication through the @opensaas/stack-auth package, which integrates Better-auth with the stack's access control system. You get:
- Email/password authentication out of the box
- OAuth/social login (GitHub, Google, Discord, Twitter)
- Email verification and password reset flows
- Session management with secure HTTP-only cookies
- Pre-built UI components for common auth flows
- Automatic session injection into access control functions
- Type-safe sessions with configurable fields
Authentication setup takes less than 5 minutes, and the session is automatically available throughout your application.
Quick Start
Here's the minimal setup to add authentication to your app:
// opensaas.config.ts
import { config, list } from '@opensaas/stack-core'
import { text } from '@opensaas/stack-core/fields'
import { authPlugin } from '@opensaas/stack-auth'
export default config({
plugins: [
authPlugin({
emailAndPassword: { enabled: true },
sessionFields: ['userId', 'email', 'name'],
}),
],
db: { provider: 'postgresql' },
lists: {
Post: list({ fields: { title: text() } }),
},
})
<!-- doc-check: excuses="TS2307 '../opensaas.config', TS2305 'rawOpensaasContext'" reason="the reader's own config and the generated bundle's rawOpensaasContext, which only exist in a project pnpm generate has run" -->
// 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
<!-- doc-check: excuses="TS2307 '@/lib/auth'" reason="the reader's own file, authored in the block above" -->
// app/api/auth/[...all]/route.ts
export { GET, POST } from '@/lib/auth'
That's it! You now have authentication endpoints and auto-generated User, Session, Account, and Verification lists.
Installation & Setup
1. Install the Package
pnpm add @opensaas/stack-auth
2. Set Environment Variables
Create a .env file with the following. Postgres is the only provider, and DATABASE_URL is read from the environment rather than declared in your config — omit it in local development and opensaas dev provisions a Dev database for you.
# Database
DATABASE_URL=postgresql://localhost:5432/myapp
# Better Auth
BETTER_AUTH_SECRET=your_secret_key_here # Generate with: openssl rand -base64 32
BETTER_AUTH_URL=http://localhost:3000
# Public URL
NEXT_PUBLIC_APP_URL=http://localhost:3000
# OAuth (optional)
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
3. Configure the Auth Plugin
Add the auth plugin to your OpenSaaS config:
// 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,
},
passwordReset: {
enabled: true,
tokenExpiration: 3600,
},
sessionFields: ['userId', 'email', 'name'],
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,
},
},
}),
},
})
sessionFields names what lands on the session object your access rules see; extendUserList adds fields to the plugin's own User list. Token expirations are in seconds. The db block carries the provider and nothing about the connection — see the Config API for its complete key list.
4. Generate Database Schema
pnpm generate
pnpm dev applies the change, creating the auth tables (User, Session, Account, Verification) in your database. If it is already running, it applies them as soon as you save the config.
Creating Authentication Pages
Server Setup
Create a server-side auth instance.
getSessionFromAuth() is what turns better-auth's own resolved session into the flat Session object the stack's access rules read — it projects exactly the names you listed in sessionFields, reading them from the resolved config at runtime, so changing sessionFields takes effect without regenerating this file. It returns null only when there is genuinely no session. Pass its result straight to getContext().
Plugin data is typed Record<string, unknown> — the config carries no plugin's own type — so the read below narrows rather than asserts, and falls back to the three default names when the plugin declared none:
<!-- doc-check: excuses="TS2307 '../opensaas.config', TS2307 'next/headers', TS2305 'rawOpensaasContext'" reason="the reader's own config and the generated bundle's rawOpensaasContext, plus Next.js, none present in this repo" -->
// lib/auth.ts
import { createAuth, getSessionFromAuth } from '@opensaas/stack-auth/server'
import type { Session } from '@opensaas/stack-core'
import config from '../opensaas.config'
import { headers } from 'next/headers'
import { rawOpensaasContext } from '@/.opensaas/context'
export const auth = createAuth(config, rawOpensaasContext)
export async function getSession(): Promise<Session | null> {
const resolvedConfig = await config
const authData = resolvedConfig._pluginData?.auth
const declared =
typeof authData === 'object' && authData !== null && 'sessionFields' in authData
? authData.sessionFields
: undefined
const sessionFields = Array.isArray(declared)
? declared.filter((field): field is string => typeof field === 'string')
: ['userId', 'email', 'name']
return getSessionFromAuth(auth, sessionFields, await headers())
}
export const GET = auth.handler
export const POST = auth.handler
createAuth() takes the rawOpensaasContext promise — do not await it at module scope. It defers construction behind a lazy proxy until the config and the client are ready.
Client Setup
Create a client-side auth instance. This is for reading the session in client components (useSession) and for an explicit signOut — the sign-in, sign-up and password-reset forms below do not use it, because they submit through server actions instead:
// 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',
})
// Optional: Export individual methods for convenience
export const { signIn, signUp, signOut, useSession } = authClient
API Routes
Create the catch-all auth route:
<!-- doc-check: excuses="TS2307 '@/lib/auth'" reason="the reader's own file, authored in an earlier block" -->
// app/api/auth/[...all]/route.ts
export { GET, POST } from '@/lib/auth'
This handles all auth endpoints. The paths are better-auth's own — there is no bare /sign-in or /sign-up:
/api/auth/sign-in/email/api/auth/sign-in/social/api/auth/sign-up/email/api/auth/sign-out/api/auth/get-session/api/auth/request-password-reset/api/auth/reset-password
Auth Actions
The pre-built forms never call the auth API from the browser. Each one takes an auth action — a 'use server' function in your own app that calls auth.api.* on the instance from lib/auth.ts — as a prop, one prop per concern. The package ships the forms and the contract types; you own the four functions, conventionally in lib/actions/auth.ts.
An email action resolves to AuthActionResult ({ success: true } or { success: false, error }, whose message the form renders) and leaves the redirect to the form's redirectTo. Social sign-in has to leave the app, so its action performs a server-side redirect() to the provider and resolves to void.
<!-- doc-check: excuses="TS2307 'next/headers', TS2307 'next/navigation', TS2307 '@/lib/auth'" reason="Next.js and the reader's own file, neither present in this repo" -->
// 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') }
}
}
createAuth() appends better-auth's nextCookies() plugin, so the session cookie set inside these actions persists — there is no extra wiring for that.
Sign-In Page
Create a sign-in page using the pre-built component:
// app/sign-in/page.tsx
import { SignInForm } from '@opensaas/stack-auth/ui'
import { signInAction } from '@/lib/actions/auth'
export default function SignInPage() {
return (
<div className="container mx-auto max-w-md py-16">
<h1 className="text-3xl font-bold mb-8">Sign In</h1>
<SignInForm signInAction={signInAction} redirectTo="/admin" showSocialProviders={false} />
</div>
)
}
Sign-Up Page
Create a sign-up page:
// app/sign-up/page.tsx
import { SignUpForm } from '@opensaas/stack-auth/ui'
import { signUpAction } from '@/lib/actions/auth'
export default function SignUpPage() {
return (
<div className="container mx-auto max-w-md py-16">
<h1 className="text-3xl font-bold mb-8">Sign Up</h1>
<SignUpForm
signUpAction={signUpAction}
redirectTo="/admin"
requirePasswordConfirmation={true}
/>
</div>
)
}
Forgot Password Page
Create a password reset request page:
// app/forgot-password/page.tsx
import { ForgotPasswordForm } from '@opensaas/stack-auth/ui'
import { requestPasswordResetAction } from '@/lib/actions/auth'
export default function ForgotPasswordPage() {
return (
<div className="container mx-auto max-w-md py-16">
<h1 className="text-3xl font-bold mb-8">Reset Password</h1>
<ForgotPasswordForm requestPasswordResetAction={requestPasswordResetAction} />
</div>
)
}
Reset Password Page
The reset link in the email lands here. ResetPasswordForm takes the token from searchParams alongside its action; an empty token renders an "invalid or expired link" state rather than a password form.
// 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 (
<div className="container mx-auto max-w-md py-16">
<h1 className="text-3xl font-bold mb-8">Choose a New Password</h1>
<ResetPasswordForm resetPasswordAction={resetPasswordAction} token={token ?? ''} />
</div>
)
}
Protected Routes
Stack doesn't use Next.js middleware for authentication. Instead, protect routes at the page/component level by checking the session.
Protecting Admin Pages
Redirect or render a refusal when getSession() returns null, then hand the session to getContext(). The generated config export is a promise, so it is awaited alongside the context. serverAction is required: it is the wrapper every admin mutation goes through, and it re-reads the session on the server so a write is checked against the caller's own access rules rather than the session the page rendered with:
// app/admin/[[...admin]]/page.tsx
import { AdminUI } from '@opensaas/stack-ui'
import type { ServerActionInput } from '@opensaas/stack-ui/server'
import { getContext, config } from '@/.opensaas/context'
import { getSession } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { getUrlKey } from '@opensaas/stack-core'
async function serverAction(props: ServerActionInput) {
'use server'
const session = await getSession()
const context = await getContext(session ?? undefined)
const result = await context.serverAction(props)
if (result && typeof result === 'object' && 'success' in result && result.success) {
redirect(`/admin/${getUrlKey(props.listKey)}`)
}
return result
}
interface AdminPageProps {
params: Promise<{ admin?: string[] }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}
export default async function AdminPage({ params, searchParams }: AdminPageProps) {
const session = await getSession()
if (!session) {
redirect('/sign-in')
}
return (
<AdminUI
context={await getContext(session)}
config={await config}
params={(await params).admin}
searchParams={await searchParams}
basePath="/admin"
serverAction={serverAction}
/>
)
}
Protecting Server Actions
A write returns the row or null — null covers both "denied" and "no such row", deliberately, so the caller cannot tell which. Check it before treating the write as done. The relation field takes { connect: { id } }; there is no nested create:
<!-- doc-check: excuses="TS2307 '@/lib/auth', TS2339 'Post'" reason="the reader's own file, and Post is not one of the three lists the prelude models" -->
// lib/actions/posts.ts
'use server'
import { getContext } from '@/.opensaas/context'
import { getSession } from '@/lib/auth'
export async function createPost(title: string) {
const session = await getSession()
if (!session) {
return { success: false, error: 'Not authenticated' }
}
const context = await getContext(session)
const post = await context.db.Post.create({
data: {
title,
author: { connect: { id: String(session.userId) } },
},
})
if (post === null) {
return { success: false, error: 'Access denied' }
}
return { success: true, post }
}
Client-Side Protection
'use client'
import { useSession } from '@/lib/auth-client'
import { redirect } from 'next/navigation'
export function ProtectedComponent() {
const { data: session, isPending } = useSession()
if (isPending) {
return <div>Loading...</div>
}
if (!session) {
redirect('/sign-in')
}
return <div>Protected content for {session.user.name}</div>
}
Session Management
Server-Side Session Access
Get the current session in server components or actions:
import { getSession } from '@/lib/auth'
export default async function MyPage() {
const session = await getSession()
if (!session) {
return <div>Not signed in</div>
}
return (
<div>
<h1>Welcome, {String(session.name)}!</h1>
<p>Email: {String(session.email)}</p>
</div>
)
}
The object getSession() returns is flat — one key per name in sessionFields, projected off better-auth's own resolved session. With sessionFields: ['userId', 'email', 'name'] it is:
<!-- doc-check: whole="a bare object-shape illustration — the flat getSession() projection's keys written as pseudo-fields, not a statement" -->
{
userId: string
email: string
name: string
}
This is the same object your access rules receive as session, which is why they read session.userId rather than session.user.id. userId is special-cased to the authenticated user's id; every other name resolves against the first hit in the resolved session's top-level keys, then its user object, then its session sub-object — so a session-only field like the admin plugin's impersonatedBy is reachable too. A name that resolves nowhere is omitted and logs a warning once per process, rather than silently surfacing as undefined inside an access rule.
If you need better-auth's own nested { user, session } shape — its token, expiresAt, ipAddress and userAgent — call auth.api.getSession() directly alongside getSession().
Client-Side Session Hook
Use the useSession() hook in client components:
'use client'
import { authClient } from '@/lib/auth-client'
export function UserProfile() {
const { data: session, isPending, error } = authClient.useSession()
if (isPending) {
return <div>Loading...</div>
}
if (error) {
return <div>Error: {error.message}</div>
}
if (!session) {
return <div>Not signed in</div>
}
return (
<div>
<img src={session.user.image || '/default-avatar.png'} alt="Avatar" />
<h2>{session.user.name}</h2>
<p>{session.user.email}</p>
</div>
)
}
Session Configuration
Configure which fields are available in the session:
<!-- doc-check: excuses="authPlugin" reason="a bare name the prose supplies" -->
authPlugin({
sessionFields: ['userId', 'email', 'name', 'role', 'company'],
})
These fields will be:
- Available in the session object
- Automatically typed in TypeScript
- Passed to all access control functions
Access Control Integration
The session is automatically injected into all access control functions. This makes it easy to implement user-based permissions.
Operation-Level Access Control
A list's access carries exactly one member, operation, with a rule per operation. create must return a boolean — there is no row to test yet, so a filter result throws InvalidCreateAccessResultError rather than being taken as an allow. query, update and delete may return either a boolean or a filter:
Post: list({
fields: {
title: text(),
content: text(),
status: select({
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
}),
author: relationship({ ref: 'User.posts' }),
},
access: {
operation: {
query: () => true,
create: ({ session }) => !!session,
update: ({ session }) => (session ? { authorId: { equals: session.userId } } : false),
delete: ({ session }) => (session ? { authorId: { equals: session.userId } } : false),
},
},
})
Scoping rows with a returned filter
There is no separate filter block. A rule scopes rows by returning a Prisma-shaped filter, which is ANDed into whatever where the caller supplied — so a rule can only ever narrow what a session sees, never widen it:
<!-- doc-check: excuses="TS2345" reason="elides fields deliberately — the section illustrates the returned-filter shape, not a complete list" -->
Post: list({
access: {
operation: {
query: ({ session }) =>
session
? {
OR: [{ status: { equals: 'published' } }, { authorId: { equals: session.userId } }],
}
: { status: { equals: 'published' } },
},
},
})
Write the anonymous branch out explicitly, as above. A rule spelled ({ session }) => ({ authorId: { equals: session?.userId } }) does not fall back to an open read: an undefined condition is refused with a ValidationError, never dropped, so the read fails closed.
The filter vocabulary is a closed set — equals, not, in, notIn, lt, lte, gt, gte, contains on a scalar; some, every, none on a relationship; AND, OR, NOT. A bare value means equality, and contains is case-insensitive. There is no startsWith, endsWith or mode.
Field-Level Access Control
Field rules are declared on the field and return a boolean only — a field decision is per-field visibility, not a row filter, and a non-boolean result throws InvalidFieldAccessResultError. A denied field is stripped from the returned row; the rest of the row comes back normally:
Post: list({
fields: {
title: text(),
content: text(),
internalNotes: text({
access: {
read: ({ session, item }) => session?.userId === item.authorId,
create: ({ session }) => !!session,
update: ({ session, item }) => session?.userId === item.authorId,
},
}),
},
})
Access Control Helpers
Extract the rules you repeat. AccessControl is the type for an operation-level rule, so isAuthor below can return a filter while isSignedIn and isAdmin return booleans:
// opensaas.config.ts
import { config, list } from '@opensaas/stack-core'
import { text } from '@opensaas/stack-core/fields'
import type { AccessControl } from '@opensaas/stack-core'
const isSignedIn: AccessControl = ({ session }) => !!session
const isAuthor: AccessControl = ({ session }) =>
session ? { authorId: { equals: session.userId } } : false
const isAdmin: AccessControl = ({ session }) => session?.role === 'admin'
export default config({
db: { provider: 'postgresql' },
lists: {
Post: list({
fields: { title: text() },
access: {
operation: {
create: isSignedIn,
update: isAuthor,
delete: isAuthor,
},
},
}),
},
})
isSignedIn and isAdmin are safe on create; isAuthor is not, because it returns a filter. Reach for the boolean form wherever a rule may land on create.
User List Customization
The auth plugin auto-generates a User list, but you can extend it with custom fields.
Adding Custom Fields
<!-- doc-check: excuses="authPlugin" reason="a bare name the prose supplies" -->
authPlugin({
extendUserList: {
fields: {
// Add a role field
role: select({
options: [
{ label: 'User', value: 'user' },
{ label: 'Admin', value: 'admin' },
{ label: 'Moderator', value: 'moderator' },
],
defaultValue: 'user',
}),
// Add company and phone
company: text(),
phoneNumber: text(),
// Add relationships
posts: relationship({
ref: 'Post.author',
many: true,
}),
},
},
})
Including Custom Fields in Session
Make custom fields available in the session:
<!-- doc-check: excuses="authPlugin, TS2345" reason="a bare name the prose supplies, and select({/* ... */}) is a placeholder, not a complete field config" -->
authPlugin({
sessionFields: ['userId', 'email', 'name', 'role', 'company'],
extendUserList: {
fields: {
role: select({/* ... */}),
company: text(),
},
},
})
session.role is now available in every access control function:
<!-- doc-check: whole="a bare access: object-literal property, not a statement" -->
access: {
operation: {
delete: ({ session }) => session?.role === 'admin',
},
}
Custom Access Control on User List
The User list ships closed by default (see Access control: closed by default). extendUserList.access is the User-specific way to grant it — it predates the more general authPlugin({ access: { user: ... } }) passthrough and takes precedence over access.user when both are set:
<!-- doc-check: excuses="authPlugin, TS7031 'session'" reason="a bare name the prose supplies, and the hook's parameters are typed by the list it is attached to" -->
authPlugin({
extendUserList: {
access: {
operation: {
// Only admins can view all users
query: ({ session }) => {
if (!session) return false
if (session.role === 'admin') return true
return { id: { equals: session.userId } }
},
// Only admins can delete users
delete: ({ session }) => session?.role === 'admin',
},
},
},
})
Custom Hooks on User List
Add lifecycle hooks to the User list. afterOperation's arguments are a union over the operation — only create and update carry item, and delete carries originalItem instead — so narrow on args.operation before reaching for a row:
<!-- doc-check: excuses="authPlugin, TS7006 'args', sendWelcomeEmail" reason="a bare name the prose supplies, plus a hook parameter and an application-side helper this repo does not define" -->
authPlugin({
extendUserList: {
hooks: {
afterOperation: async (args) => {
if (args.operation !== 'create') return
await sendWelcomeEmail(args.item.email)
},
},
},
})
OAuth/Social Login
Add OAuth providers for social login.
Configure OAuth Providers
<!-- doc-check: excuses="authPlugin" reason="a bare name the prose supplies" -->
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!,
},
twitter: {
clientId: process.env.TWITTER_CLIENT_ID!,
clientSecret: process.env.TWITTER_CLIENT_SECRET!,
},
},
})
Supported providers: github, google, discord, twitter
Setting Up OAuth Apps
GitHub
- Go to GitHub Developer Settings
- Create a new OAuth App
- Set Authorization callback URL to:
http://localhost:3000/api/auth/callback/github - Copy Client ID and Client Secret to
.env
- Go to Google Cloud Console
- Create a new project or select existing
- Enable Google+ API
- Create OAuth 2.0 credentials
- Set Authorized redirect URI to:
http://localhost:3000/api/auth/callback/google - Copy Client ID and Client Secret to
.env
Using Social Login in UI
OAuth needs its own action, because it navigates away from your app instead of returning a result to the form: it asks better-auth for the provider URL and redirects there.
<!-- doc-check: excuses="TS2307 'next/headers', TS2307 'next/navigation', TS2307 '@/lib/auth'" reason="Next.js and the reader's own file, neither present in this repo" -->
// lib/actions/auth.ts
'use server'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
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)
}
}
Pass it to the form to get OAuth buttons for the listed providers. The buttons render only when signInSocialAction is present — showSocialProviders on its own is not enough:
// 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}
showSocialProviders={true}
socialProviders={['github', 'google']}
/>
)
}
Custom OAuth Button Styling
You can customize the OAuth buttons using CSS:
.auth-provider-button {
/* Your custom styles */
}
.auth-provider-button[data-provider='github'] {
/* GitHub-specific styles */
}
Email Verification & Password Reset
Configure email flows for verification and password reset.
Email Configuration
sendResetPassword/sendVerificationEmail are forwarded straight through to better-auth's own emailAndPassword/emailVerification options — no stack wrapping. Each receives exactly what better-auth passes (user, url, token), so you build the subject line and body yourself:
<!-- doc-check: excuses="authPlugin, TS7031 'user', TS7031 'url', resend" reason="a bare name the prose supplies, plus better-auth's own callback parameters and an email client this repo does not install" -->
authPlugin({
emailVerification: {
enabled: true,
sendOnSignUp: true, // Send verification email on sign-up
tokenExpiration: 86400, // 24 hours
sendVerificationEmail: async ({ user, url }) => {
await resend.emails.send({
from: 'noreply@yourapp.com',
to: user.email,
subject: 'Verify your email',
html: `<a href="${url}">Verify your email</a>`,
})
},
},
passwordReset: {
enabled: true,
tokenExpiration: 3600, // 1 hour
},
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url }) => {
await resend.emails.send({
from: 'noreply@yourapp.com',
to: user.email,
subject: 'Reset your password',
html: `<a href="${url}">Reset your password</a>`,
})
},
},
})
Using Resend
<!-- doc-check: excuses="TS2307 'resend'" reason="a third-party SDK this repo does not install" -->
import { Resend } from 'resend'
import { authPlugin } from '@opensaas/stack-auth'
const resend = new Resend(process.env.RESEND_API_KEY)
authPlugin({
emailVerification: {
enabled: true,
sendVerificationEmail: async ({ user, url }) => {
await resend.emails.send({
from: 'onboarding@resend.dev',
to: user.email,
subject: 'Verify your email',
html: `<a href="${url}">Verify your email</a>`,
})
},
},
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url }) => {
await resend.emails.send({
from: 'onboarding@resend.dev',
to: user.email,
subject: 'Reset your password',
html: `<a href="${url}">Reset your password</a>`,
})
},
},
})
Using SendGrid
<!-- doc-check: excuses="TS2307 '@sendgrid/mail'" reason="a third-party SDK this repo does not install" -->
import sgMail from '@sendgrid/mail'
import { authPlugin } from '@opensaas/stack-auth'
sgMail.setApiKey(process.env.SENDGRID_API_KEY!)
authPlugin({
emailVerification: {
enabled: true,
sendVerificationEmail: async ({ user, url }) => {
await sgMail.send({
from: 'noreply@yourapp.com',
to: user.email,
subject: 'Verify your email',
html: `<a href="${url}">Verify your email</a>`,
})
},
},
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url }) => {
await sgMail.send({
from: 'noreply@yourapp.com',
to: user.email,
subject: 'Reset your password',
html: `<a href="${url}">Reset your password</a>`,
})
},
},
})
Development Mode
If no sendVerificationEmail/sendResetPassword callback is provided, emails are logged to the console in development:
📧 Email would be sent to: user@example.com
Subject: Verify your email
Token: abc123def456
Verification link: http://localhost:3000/api/auth/verify-email?token=abc123def456
Email Verification Flow
- User signs up
- Verification email sent with token
- User clicks link:
/api/auth/verify-email?token=... - Better-auth verifies token and marks email as verified
- User can now sign in
Password Reset Flow
- User requests password reset
- Reset email sent with token
- User clicks link and enters new password
- Better-auth verifies token and updates password
- User can sign in with new password
Adopting an Existing better-auth Installation
If you are migrating a project that already runs better-auth — its tables exist, hold live data, and you don't want a destructive auth migration — the plugin can adopt those tables instead of recreating them. This is the common case when migrating an established app to Stack.
App User vs the Auth identity
A migrating app almost always has two distinct concepts, and conflating them is the main pitfall:
- The application's domain
User— your own model (public.User), keyed however your app likes (e.g. asubjectId), carrying your domain fields (profile, billing, roles, relationships to your other lists). - The Auth identity — the better-auth-owned user record (
AuthUser), the thing a session belongs to. It owns sessions, OAuth accounts, and credentials.
The auth plugin models the Auth identity (the User/Session/Account/ Verification lists better-auth needs). It does not assume its user list is your app's User. As long as the plugin's user model key differs from your app list's key (e.g. the plugin uses AuthUser while your app keeps User), your domain User is left completely untouched — never extended, never overwritten.
Keep these separate. The plugin owns the Auth identity; your app owns its domain User. Linking the two is your application's concern — see Linking your app User to the Auth identity below.
The adoptBetterAuthTables() recipe
Rather than hand-write the four modelNames and a schema on every model, use the adoptBetterAuthTables() recipe. It returns an auth-config fragment with the adoption knobs already set to the conventions of a standard separate-schema better-auth install, and you spread it into authPlugin alongside the rest of your config:
The spread supplies the adoption defaults — AuthUser/AuthSession/ AuthAccount/AuthVerification in the auth Postgres schema, matching a live better-auth install — and the rest of your auth config composes on top of it as normal. Your own domain User stays in public and is not touched by the plugin:
import { config, list } from '@opensaas/stack-core'
import { text } from '@opensaas/stack-core/fields'
import { authPlugin, adoptBetterAuthTables } from '@opensaas/stack-auth'
export default config({
db: { provider: 'postgresql' },
plugins: [
authPlugin({
...adoptBetterAuthTables(),
emailAndPassword: { enabled: true },
sessionFields: ['userId', 'email', 'name'],
}),
],
lists: {
User: list({
fields: {
subjectId: text({ validation: { isRequired: true } }),
},
}),
},
})
With these defaults the plugin derives AuthUser/AuthSession/AuthAccount/ AuthVerification, pins each to its live table name (@@map) and the auth schema (@@schema), and wires Postgres multi-schema automatically. The generated Auth lists therefore reach Schema parity — they diff clean against your live tables, so adding the plugin produces no destructive auth migration. The lists are modelled for runtime and types, not migrated.
Regenerate, then plan against your live database (with DATABASE_URL pointing at it) — the plan should list no changes to the auth tables (other tables in the database may appear):
pnpm generate
npx prisma db update --dry-run
Customising the recipe
The recipe takes options when your live tables diverge from the defaults:
<!-- doc-check: excuses="adoptBetterAuthTables" reason="a bare name the prose supplies" -->
adoptBetterAuthTables({
// Postgres schema the live tables live in (default: 'auth')
schema: 'identity',
// Prefix applied to each model name (default: 'Auth' → AuthUser/AuthSession/…)
modelNamePrefix: 'BA', // → BAUser/BASession/BAAccount/BAVerification
// Column renames, only where your live columns differ from better-auth defaults
fields: {
user: { name: 'full_name', emailVerified: 'is_verified' },
session: { userId: 'user_id' },
},
})
The recipe is just a convenience over the plugin's own schema / per-model modelName / fields options — anything it sets you can also set directly on authPlugin, or override after spreading it in.
If your live install also runs better-auth's database-backed rate limiter, opt the recipe into adopting that table too with rateLimit: true — it's false by default since most installs use the in-memory limiter:
<!-- doc-check: excuses="adoptBetterAuthTables" reason="a bare name the prose supplies" -->
adoptBetterAuthTables({ rateLimit: true })
// → adds rateLimit: { enabled: true, storage: 'database', modelName: 'AuthRateLimit' }
Adopting better-auth's default table names
The most common migration shape isn't the renamed-table one above — it's a project that ran better-auth before adding Stack, so its tables are still better-auth's own default lowercase names (user, session, account, verification). Your app almost always also has its own domain User, so the Auth identity still needs a prefixed list key (AuthUser) to avoid a collision — but that prefixed key must map to the unprefixed live table, not a table named AuthUser.
modelName and the physical table name are independent for exactly this reason: modelName sets the list key, and a separate tableName pins the @@map. Pass useBetterAuthTableNames: true to point every model's table name at better-auth's own defaults while keeping the Auth-prefixed list keys:
<!-- doc-check: excuses="authPlugin, adoptBetterAuthTables" reason="bare names the prose supplies" -->
authPlugin({
...adoptBetterAuthTables({ schema: 'auth', useBetterAuthTableNames: true }),
emailAndPassword: { enabled: true },
})
// List keys: AuthUser / AuthSession / AuthAccount / AuthVerification
// Live tables: user / session / account / verification (via @@map)
For a mix — most tables use better-auth's defaults but one was renamed — use the per-model tableNames escape hatch instead (it takes precedence over useBetterAuthTableNames for any model it names):
<!-- doc-check: excuses="adoptBetterAuthTables" reason="a bare name the prose supplies" -->
adoptBetterAuthTables({
useBetterAuthTableNames: true,
tableNames: { user: 'app_users' }, // only the user table was renamed
})
The same knob is available directly on authPlugin without the recipe, for a single model:
<!-- doc-check: excuses="authPlugin" reason="a bare name the prose supplies" -->
authPlugin({
user: { modelName: 'AuthUser', tableName: 'user' },
session: { modelName: 'AuthSession', tableName: 'session' },
account: { modelName: 'AuthAccount', tableName: 'account' },
verification: { modelName: 'AuthVerification', tableName: 'verification' },
})
With no tableName set, behaviour is unchanged from before this option existed: the table name follows modelName whenever it differs from the better-auth default, so a renamed-table install (the adoptBetterAuthTables() default from the previous section) keeps working without any changes.
Adopting a live constraint name or adding your own index (indexes)
Each per-model block (user/session/account/verification/rateLimit) also accepts indexes, in the same shape as a list's own db.indexes (see the db.indexes reference) — entries name the model's own field keys, not raw column names.
The stack already derives some indexes from better-auth's own table definitions — User.email and Session.token are unique, for example. If your live database's constraint has a different name than the one Prisma would derive, adopting it under its real name is a generate-clean diff away:
<!-- doc-check: excuses="authPlugin" reason="a bare name the prose supplies" -->
authPlugin({
user: { indexes: [{ fields: ['email'], unique: true, name: 'user_email_key' }] },
session: { indexes: [{ fields: ['token'], unique: true, name: 'session_token_key' }] },
})
An entry covering a column the stack already derives an index for replaces that derived index rather than erroring — your declaration wins. This is the opposite of db.indexes on a list you declare yourself, where a collision with a field's own isIndexed is a config-time error: here, one of the two declarations is derived, so there's nothing of yours to remove.
The same seam lets you extend a derived column into a composite index — e.g. a per-identifier resend-cooldown check on the verification table:
<!-- doc-check: excuses="authPlugin" reason="a bare name the prose supplies" -->
authPlugin({
verification: {
indexes: [{ fields: ['identifier', 'createdAt'] }],
},
})
This suppresses the derived single-column index on identifier in favor of the composite, which serves the same lookups. Suppression is per-column: every other index the stack derives for that model is unaffected.
An index column carries no sort direction — ListIndexFieldRef has no sort key, so { field: 'createdAt', sort: 'desc' } is a TypeScript excess-property error rather than a generate-time refusal. The index keeps the column order you declared, which is what serves the lookup.
Linking your app User to the Auth identity
Because the Auth identity (AuthUser) and your domain User are separate models, your application declares the link — the plugin never imposes a single-User-model assumption. Add a relationship from your domain User to the Auth identity:
<!-- doc-check: whole="a bare lists: object-literal property, not a statement" -->
lists: {
User: list({
fields: {
subjectId: text({ validation: { isRequired: true } }),
// The app owns the link to the Auth identity:
authIdentity: relationship({ ref: 'AuthUser' }),
},
}),
}
Then resolve from a session's userId (the Auth identity's id) to your domain User in your own code — e.g. look up the domain User whose authIdentity points at session.userId. How you key and resolve that link is entirely up to your app.
Auto-Generated Lists
The auth plugin automatically creates these lists in your database:
Access control: closed by default
Per ADR-0013, access control belongs to the application, not the plugin. The four Auth lists (User/Session/Account/Verification) ship with no operation-level access — with nothing configured, context.db reads/writes against them return null/[] and they don't appear in the admin UI. This does not affect sign-in/sign-up/session flows: better-auth talks to these tables through its own adapter, outside access control entirely.
Grant access explicitly with authPlugin({ access: { … } }), keyed by better-auth model name (user/session/account/verification, not the derived list key — so it keeps working if you rename a model via modelName). Each entry is a list access config: an operation block, and nothing else.
Below, signed-in users can browse the directory but only write themselves; sessions and accounts are scoped to their owner by a returned filter. Crossing the user relationship needs a quantifier — some here — because a relationship key in a filter takes only some, every or none. Verification tokens stay closed: better-auth manages them directly.
<!-- doc-check: excuses="authPlugin, TS7031 'session', TS7031 'item'" reason="a bare name the prose supplies, and each rule's parameters are typed by the list access config it is attached to" -->
authPlugin({
access: {
user: {
operation: {
query: ({ session }) => !!session,
update: ({ session, item }) => session?.userId === item.id,
delete: ({ session, item }) => session?.userId === item.id,
},
},
session: {
operation: {
query: ({ session }) =>
session ? { user: { some: { id: { equals: session.userId } } } } : false,
},
},
account: {
operation: {
query: ({ session }) =>
session ? { user: { some: { id: { equals: session.userId } } } } : false,
},
},
},
})
There is no fields block here, and none is needed for the credential columns: Account.accessToken/refreshToken/password, Session.token and Verification.identifier/value ship field-level read-denied already (ADR-0036), so granting operation access above does not expose them. See Credential fields are read-denied for the full set. To deny a further field, declare the rule on the field itself — via extendUserList.fields for the User list.
A whole-row update rule does not imply every column is writable. The update rule above says who may write the User row — it says nothing about which columns. User.emailVerified is already write-denied unconditionally (better-auth marks it input: false), and if you register the admin() plugin, so are User.role, User.banned, User.banReason, User.banExpires and Session.impersonatedBy — a signed-in user can't self-verify or self-promote through the rule above (ADR-0073). This matters if you add your own sensitive field via extendUserList.fields (see Adding Custom Fields below) — the whole-row rule permits writing it too, so protect it explicitly with authPlugin({ fieldAccess }), keyed by better-auth model key then field key:
<!-- doc-check: excuses="authPlugin, admin, TS7031 'session'" reason="bare names the prose supplies (admin() is better-auth's own plugin, imported from 'better-auth/plugins'), and each rule's parameters are typed by the list access config it is attached to" -->
authPlugin({
betterAuthPlugins: [admin()],
fieldAccess: {
user: {
role: { update: ({ session }) => session?.role === 'admin' },
},
},
})
See Fields are write-denied independent of operation access for the full rules, including why fieldAccess can never reopen a credential's read deny.
For the user model specifically, extendUserList.access (the pre-existing User customization surface — see Custom Access Control on User List) is still honored and takes precedence over access.user if both are set.
Migrating from an older version: if you relied on the previous permissive defaults (query: () => true, self-only update/delete on User; session-owner filters on Session/Account; closed Verification), add the equivalent under authPlugin({ access: { ... } }) — the first three blocks above reproduce that behavior for user/session/account. Without it, context.db reads on these lists (and their admin UI pages) go from visible to empty.
User List
<!-- doc-check: whole="a bare object-shape illustration of the User list's row, not a statement" -->
{
id: string // Auto-generated UUID
email: string // Unique, required
emailVerified: boolean // Email verification status
name: string | null // Display name
image: string | null // Avatar URL
createdAt: DateTime // Auto-generated
updatedAt: DateTime // Auto-updated
// Relationships
sessions: Session[] // User's sessions
accounts: Account[] // OAuth accounts
// Custom fields from extendUserList
...customFields
}
Access Control: closed by default (see Access control: closed by default above) — grant it via authPlugin({ access: { user: { ... } } }) or extendUserList.access. Sign-up still works with no access configured: better-auth creates the row through the raw client, bypassing access control.
Session List
<!-- doc-check: whole="a bare object-shape illustration of the Session list's row, not a statement" -->
{
id: string // Auto-generated UUID
userId: string // Foreign key to User
token: string // Session token (unique)
expiresAt: DateTime // Session expiration
ipAddress: string | null // Client IP
userAgent: string | null // Client user agent
createdAt: DateTime // Auto-generated
updatedAt: DateTime // Auto-updated
// Relationships
user: User // Session owner
}
Access Control: closed by default — grant it via authPlugin({ access: { session: { ... } } }).
Account List
Stores OAuth provider information and password hashes:
<!-- doc-check: whole="a bare object-shape illustration of the Account list's row, not a statement" -->
{
id: string // Auto-generated UUID
userId: string // Foreign key to User
accountId: string // Provider-specific user ID
providerId: string // 'github', 'google', 'credential' (email/password)
issuer: string // The identity issuer this account is scoped to
accessToken: string | null // OAuth access token
refreshToken: string | null // OAuth refresh token
idToken: string | null // OIDC id token
accessTokenExpiresAt: DateTime | null
refreshTokenExpiresAt: DateTime | null
scope: string | null // Granted OAuth scopes
password: string | null // Hashed password (for email/password)
createdAt: DateTime // Auto-generated
updatedAt: DateTime // Auto-updated
// Relationships
user: User // Account owner
}
Access Control: closed by default — grant it via authPlugin({ access: { account: { ... } } }). Consider hiding accessToken/refreshToken/password at the field level even when you grant operation access (see the example above).
Verification List
Stores email verification and password reset tokens:
<!-- doc-check: whole="a bare object-shape illustration of the Verification list's row, not a statement" -->
{
id: string // Auto-generated UUID
identifier: string // Email address
value: string // Token value
expiresAt: DateTime // Token expiration
createdAt: DateTime // Auto-generated
updatedAt: DateTime // Auto-updated
}
Access Control: closed by default — better-auth manages these tokens directly through the raw client, so most apps never need to grant access.verification at all.
RateLimit List
Only present when rateLimit.storage: 'database' is set — mirrors better-auth's own rate-limit table exactly, so no createdAt/updatedAt and no defaults on any column (the limiter supplies lastRequest explicitly on every write):
<!-- doc-check: whole="a bare object-shape illustration of the RateLimit list's row, not a statement" -->
{
id: string // Auto-generated UUID
key: string // Unique — the rate-limit bucket key (e.g. "ip|/sign-in/email")
count: number // Requests seen in the current window
lastRequest: bigint // Millisecond epoch of the last request
}
<!-- doc-check: excuses="authPlugin" reason="a bare name the prose supplies" -->
authPlugin({
rateLimit: { enabled: true, storage: 'database' },
})
Access Control: closed by default — grant it via authPlugin({ access: { rateLimit: { ... } } }) (e.g. to inspect throttled keys in the Admin UI).
Best Practices
Security
Use Strong Secrets
bash# Generate with: openssl rand -base64 32Silent Failures Access-denied operations return
null(a single row),[](a list) or a zeroed aggregate instead of throwing. There is noAccessDeniedErrorto catch — anullcovers both "no such row" and "not allowed", and that ambiguity is the point:<!-- doc-check: whole="a fragment continuing the prose above —
idanddataare shorthand properties with nothing in scope to supply them, andPostis not one of the three lists the prelude models" -->typescriptconst post = await context.db.Post.update({ where: { id }, data }) if (post === null) { return { error: 'Access denied' } }Never Expose Sensitive Fields A
password()field is excluded from reads for you. Anything else you want hidden needs its own field-levelreadrule, which returns a boolean:<!-- doc-check: whole="a bare
fields:object-literal property, not a statement" -->typescriptfields: { password: password(), apiKey: text({ access: { read: ({ session, item }) => session?.userId === item.id, }, }), }Validate on Both Client and Server Always validate in server actions, even if client validates: <!-- doc-check: excuses="postSchema" reason="a bare name the prose supplies" -->
typescript'use server' export async function createPost(data: unknown) { // Server-side validation const validated = postSchema.parse(data) // ... }
Session Management
Configure Session Expiration
<!-- doc-check: excuses="authPlugin" reason="a bare name the prose supplies" -->
typescriptauthPlugin({ session: { expiresIn: 604800, // 7 days updateAge: 86400, // Refresh session every 1 day; set `false` to disable }, })Include Only Necessary Fields Don't include sensitive data in session fields: <!-- doc-check: whole="two
sessionFields:alternatives shown side by side, not a statement" -->typescriptsessionFields: ['userId', 'email', 'name', 'role'], // Good sessionFields: ['userId', 'password', 'apiKey'], // Bad!
Project Structure
Recommended structure for auth-enabled apps:
app/
├── sign-in/
│ └── page.tsx # Sign in page
├── sign-up/
│ └── page.tsx # Sign up page
├── forgot-password/
│ └── page.tsx # Password reset request
├── reset-password/
│ └── page.tsx # Password reset (target of the email link)
├── admin/
│ └── [[...admin]]/
│ └── page.tsx # Protected admin area
└── api/
└── auth/
└── [...all]/
└── route.ts # Auth API routes
lib/
├── auth.ts # Server auth instance
├── auth-client.ts # Client auth instance (session reading only)
└── actions/
├── auth.ts # Auth actions the pre-built forms submit through
└── *.ts # Other server actions with auth
opensaas.config.ts # Config with authPlugin
Environment Variables
Always use environment variables for sensitive data:
<!-- doc-check: excuses="authPlugin" reason="a bare name the prose supplies" -->
// Good
authPlugin({
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
})
// Bad
authPlugin({
socialProviders: {
github: {
clientId: 'hardcoded_client_id', // Never do this!
clientSecret: 'hardcoded_secret',
},
},
})
Error Handling
The pre-built forms already render the error an action returns. In a form of your own, the same contract applies: an auth action resolves to an AuthActionResult rather than throwing, so a failed sign-in arrives as { success: false, error } and there is nothing to catch:
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { signInAction } from '@/lib/actions/auth'
export function SignInButton() {
const [error, setError] = useState<string | null>(null)
const router = useRouter()
async function handleSignIn(email: string, password: string) {
const result = await signInAction({ email, password })
if (result.success) {
router.push('/admin')
} else {
setError(result.error)
}
}
return (
<div>
{error && <div className="text-red-500">{error}</div>}
{/* Form */}
</div>
)
}
Complete Example
Here's a complete working example of an authenticated blog application:
The User list ships closed by default (ADR-0013), so the config grants it explicitly. Post.query scopes reads by returning a filter rather than declaring one in a separate block:
// opensaas.config.ts
import { config, list } from '@opensaas/stack-core'
import { text, select, relationship } from '@opensaas/stack-core/fields'
import { authPlugin } from '@opensaas/stack-auth'
import type { AccessControl } from '@opensaas/stack-core'
const isSignedIn: AccessControl = ({ session }) => !!session
const isAuthor: AccessControl = ({ session }) =>
session ? { authorId: { equals: session.userId } } : false
export default config({
plugins: [
authPlugin({
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
},
passwordReset: {
enabled: true,
},
sessionFields: ['userId', 'email', 'name'],
extendUserList: {
fields: {
posts: relationship({
ref: 'Post.author',
many: true,
}),
},
},
access: {
user: {
operation: {
query: isSignedIn,
update: ({ session, item }) => session?.userId === item.id,
},
},
},
}),
],
db: { provider: 'postgresql' },
lists: {
Post: list({
fields: {
title: text({
validation: { isRequired: true },
}),
content: text({
validation: { isRequired: true },
}),
status: select({
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
}),
author: relationship({
ref: 'User.posts',
}),
},
access: {
operation: {
query: ({ session }) =>
session
? {
OR: [
{ status: { equals: 'published' } },
{ authorId: { equals: session.userId } },
],
}
: { status: { equals: 'published' } },
create: isSignedIn,
update: isAuthor,
delete: isAuthor,
},
},
}),
},
})
<!-- doc-check: excuses="TS2307 '../opensaas.config', TS2305 'rawOpensaasContext', TS2307 'next/headers'" reason="the reader's own config and the generated bundle's rawOpensaasContext, plus Next.js, none present in this repo" -->
// lib/auth.ts
import { createAuth, getSessionFromAuth } from '@opensaas/stack-auth/server'
import type { Session } from '@opensaas/stack-core'
import config from '../opensaas.config'
import { rawOpensaasContext } from '@/.opensaas/context'
import { headers } from 'next/headers'
export const auth = createAuth(config, rawOpensaasContext)
export async function getSession(): Promise<Session | null> {
const resolvedConfig = await config
const authData = resolvedConfig._pluginData?.auth
const declared =
typeof authData === 'object' && authData !== null && 'sessionFields' in authData
? authData.sessionFields
: undefined
const sessionFields = Array.isArray(declared)
? declared.filter((field): field is string => typeof field === 'string')
: ['userId', 'email', 'name']
return getSessionFromAuth(auth, sessionFields, await headers())
}
export const GET = auth.handler
export const POST = auth.handler
<!-- doc-check: excuses="TS2307 'next/headers', TS2307 '@/lib/auth'" reason="Next.js and the reader's own file, neither present in this repo" -->
// lib/actions/auth.ts
'use server'
import { headers } from 'next/headers'
import { auth } from '@/lib/auth'
import type { AuthActionResult, SignInInput } from '@opensaas/stack-auth/ui'
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) {
const message = err instanceof Error && err.message ? err.message : 'Sign in failed'
return { success: false, error: message }
}
}
// app/sign-in/page.tsx
import { SignInForm } from '@opensaas/stack-auth/ui'
import { signInAction } from '@/lib/actions/auth'
export default function SignInPage() {
return (
<div className="container mx-auto max-w-md py-16">
<h1 className="text-3xl font-bold mb-8">Sign In</h1>
<SignInForm signInAction={signInAction} redirectTo="/admin" showSocialProviders={false} />
</div>
)
}
// app/admin/[[...admin]]/page.tsx
import { AdminUI } from '@opensaas/stack-ui'
import type { ServerActionInput } from '@opensaas/stack-ui/server'
import { getContext, config } from '@/.opensaas/context'
import { getSession } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { getUrlKey } from '@opensaas/stack-core'
async function serverAction(props: ServerActionInput) {
'use server'
const session = await getSession()
const context = await getContext(session ?? undefined)
const result = await context.serverAction(props)
if (result && typeof result === 'object' && 'success' in result && result.success) {
redirect(`/admin/${getUrlKey(props.listKey)}`)
}
return result
}
interface AdminPageProps {
params: Promise<{ admin?: string[] }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}
export default async function AdminPage({ params, searchParams }: AdminPageProps) {
const session = await getSession()
if (!session) {
redirect('/sign-in')
}
return (
<AdminUI
context={await getContext(session)}
config={await config}
params={(await params).admin}
searchParams={await searchParams}
basePath="/admin"
serverAction={serverAction}
/>
)
}
Troubleshooting
"Session is null" in Access Control
Make sure you're passing the session when creating the context, and that you're passing the flat projection getSession() returns rather than better-auth's nested { user, session } object — access rules read session.userId, which only the projection carries:
<!-- doc-check: excuses="TS2451 'context', getContext, getSession" reason="a ❌/✅ pair shares one fence and both snippets redeclare context" -->
// ❌ Anonymous — no session reaches the access rules
const context = await getContext()
// ✅ Correct
const session = await getSession()
const context = await getContext(session ?? undefined)
OAuth Redirect Not Working
Check your OAuth app configuration:
- Callback URL must match exactly:
http://localhost:3000/api/auth/callback/{provider} - Environment variables are set correctly
- App is approved/published (for Google)
Email Verification Not Sending
- Check if
emailVerification.sendVerificationEmail(oremailAndPassword.sendResetPasswordfor password reset) is configured - In development, check console for email logs
- Verify email provider API keys are set
"Access Denied" on All Operations
Check your access control configuration:
- Does the operation return
trueor a filter? - Is the session being passed correctly?
- Are you checking the right session fields?
- If this is one of the Auth lists (User/Session/Account/Verification): they ship closed by default (ADR-0013) — see Access control: closed by default. Add
authPlugin({ access: { user: { ... } } })(or the matching model key) to open it up.
TypeScript Errors on Session Fields
Make sure custom fields are included in sessionFields:
<!-- doc-check: excuses="authPlugin, TS2345" reason="a bare name the prose supplies, and select({/* ... */}) is a placeholder, not a complete field config" -->
authPlugin({
sessionFields: ['userId', 'email', 'name', 'role'], // Include 'role'
extendUserList: {
fields: {
role: select({/* ... */}),
},
},
})
Next Steps
- Package Reference: See the Auth Package docs for detailed API reference
- Access Control Guide: Learn more in the Access Control Guide
- Working Example: Check out the auth-demo example
- Better Auth Docs: Explore Better-auth documentation for advanced features
You now have everything you need to implement secure authentication in your Stack application!