Authentication Guide
This guide covers everything you need to implement authentication in your OpenSaaS Stack application using Better-auth integration.
Introduction
OpenSaaS 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 } from '@opensaas/stack-core'
import { authPlugin } from '@opensaas/stack-auth'
export default config({
plugins: [
authPlugin({
emailAndPassword: { enabled: true },
sessionFields: ['userId', 'email', 'name'],
}),
],
db: { provider: 'sqlite', url: 'file:./dev.db' },
lists: {
/* your lists */
},
})
// 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
// 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:
# Database
DATABASE_URL=file:./dev.db
# 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, text, relationship } from '@opensaas/stack-core'
import { authPlugin } from '@opensaas/stack-auth'
export default config({
plugins: [
authPlugin({
// Email and password authentication
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
requireConfirmation: true,
},
// Password reset functionality
passwordReset: {
enabled: true,
tokenExpiration: 3600, // 1 hour
},
// Fields available in session object
sessionFields: ['userId', 'email', 'name'],
// Extend User list with custom fields
extendUserList: {
fields: {
posts: relationship({ ref: 'Post.author', many: true }),
},
},
}),
],
db: {
provider: 'sqlite',
url: process.env.DATABASE_URL || 'file:./dev.db',
},
lists: {
Post: list({
fields: {
title: text(),
author: relationship({ ref: 'User.posts' }),
},
access: {
operation: {
create: ({ session }) => !!session,
},
},
}),
},
})
4. Generate Database Schema
pnpm generate
pnpm db:push
This creates the auth tables (User, Session, Account, Verification) in your database.
Creating Authentication Pages
Server Setup
Create a server-side auth instance:
// lib/auth.ts
import { createAuth } from '@opensaas/stack-auth/server'
import config from '../opensaas.config'
import { rawOpensaasContext } from '@/.opensaas/context'
// Create auth instance
export const auth = createAuth(config, rawOpensaasContext)
// Helper to get current session
export async function getAuth() {
const session = await auth.api.getSession({
headers: await headers(),
})
return session
}
// Export handlers for API routes
export const GET = auth.handler
export const POST = auth.handler
Client Setup
Create a client-side auth instance:
// 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:
// app/api/auth/[...all]/route.ts
export { GET, POST } from '@/lib/auth'
This handles all auth endpoints:
/api/auth/sign-in/api/auth/sign-up/api/auth/sign-out/api/auth/session/api/auth/forgot-password/api/auth/reset-password
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 { authClient } from '@/lib/auth-client'
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
authClient={authClient}
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 { authClient } from '@/lib/auth-client'
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
authClient={authClient}
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 { authClient } from '@/lib/auth-client'
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 authClient={authClient} />
</div>
)
}
Protected Routes
OpenSaaS Stack doesn't use Next.js middleware for authentication. Instead, protect routes at the page/component level by checking the session.
Protecting Admin Pages
// app/admin/[[...admin]]/page.tsx
import { AdminUI } from '@opensaas/stack-ui'
import { getContext, config } from '@/.opensaas/context'
import { getAuth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function AdminPage({ params, searchParams }) {
const session = await getAuth()
// Redirect unauthenticated users
if (!session) {
redirect('/sign-in')
}
// Or show an error message
if (!session) {
return (
<div className="p-8">
<div className="bg-destructive/10 border border-destructive rounded-lg p-6">
<h2 className="text-xl font-bold mb-2">Access Denied</h2>
<p className="text-muted-foreground">
You must be logged in to access the admin interface.
</p>
<a href="/sign-in" className="text-primary underline mt-4 inline-block">
Sign In
</a>
</div>
</div>
)
}
return (
<AdminUI
context={await getContext(session.user)}
config={await config}
params={params.admin}
searchParams={searchParams}
/>
)
}
Protecting Server Actions
// lib/actions/posts.ts
'use server'
import { getContext } from '@/.opensaas/context'
import { getAuth } from '@/lib/auth'
export async function createPost(data: PostCreateInput) {
const session = await getAuth()
if (!session) {
return { success: false, error: 'Not authenticated' }
}
const context = await getContext(session.user)
const post = await context.db.post.create({
data: {
...data,
author: { connect: { id: session.user.id } },
},
})
if (!post) {
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 { getAuth } from '@/lib/auth'
export default async function MyPage() {
const session = await getAuth()
if (!session) {
return <div>Not signed in</div>
}
return (
<div>
<h1>Welcome, {session.user.name}!</h1>
<p>Email: {session.user.email}</p>
</div>
)
}
The session object contains:
{
user: {
id: string
email: string
name: string | null
image: string | null
emailVerified: boolean
// ... any custom fields from sessionFields
}
session: {
token: string
expiresAt: Date
ipAddress: string | null
userAgent: string | null
}
}
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:
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
Control who can perform operations on a list:
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: {
// Anyone can view published posts
query: () => true,
// Only authenticated users can create posts
create: ({ session }) => !!session,
// Only the author can update their posts
update: ({ session, item }) => {
if (!session) return false
return session.userId === item.authorId
},
// Only the author can delete their posts
delete: ({ session, item }) => {
if (!session) return false
return session.userId === item.authorId
},
},
},
})
Filter-Based Access Control
Filter which records users can access:
Post: list({
access: {
operation: {
query: () => true, // Allow the query operation
},
filter: {
query: ({ session }) => {
// Anonymous users: only published posts
if (!session) {
return { status: { equals: 'published' } }
}
// Authenticated users: published posts + their own drafts
return {
OR: [{ status: { equals: 'published' } }, { authorId: { equals: session.userId } }],
}
},
},
},
})
Field-Level Access Control
Control access to individual fields:
Post: list({
fields: {
title: text(),
content: text(),
// Only the author can read/write internal notes
internalNotes: text({
access: {
read: ({ session, item }) => {
if (!session) return false
return session.userId === item.authorId
},
create: ({ session }) => !!session,
update: ({ session, item }) => {
if (!session) return false
return session.userId === item.authorId
},
},
}),
},
})
Access Control Helpers
Create reusable access control functions:
// opensaas.config.ts
import type { AccessControl } from '@opensaas/stack-core'
// Check if user is signed in
const isSignedIn: AccessControl = ({ session }) => {
return !!session
}
// Check if user is the author
const isAuthor: AccessControl = ({ session, item }) => {
if (!session) return false
return { authorId: { equals: session.userId } }
}
// Check if user is an admin
const isAdmin: AccessControl = ({ session }) => {
return session?.role === 'admin'
}
export default config({
lists: {
Post: list({
access: {
operation: {
create: isSignedIn,
update: isAuthor,
delete: isAuthor,
},
},
}),
User: list({
access: {
operation: {
delete: isAdmin,
},
},
}),
},
})
User List Customization
The auth plugin auto-generates a User list, but you can extend it with custom fields.
Adding Custom Fields
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:
authPlugin({
sessionFields: ['userId', 'email', 'name', 'role', 'company'],
extendUserList: {
fields: {
role: select({
/* ... */
}),
company: text(),
},
},
})
Now you can access these in access control:
access: {
operation: {
delete: ({ session }) => {
// session.role is now typed and available
return session?.role === 'admin'
},
},
}
Custom Access Control on User List
Override the default User list access control:
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:
authPlugin({
extendUserList: {
hooks: {
afterOperation: async ({ operation, item, context }) => {
if (operation === 'create') {
console.log('New user created:', item.email)
// Send welcome email
await sendWelcomeEmail(item.email)
}
},
},
},
})
OAuth/Social Login
Add OAuth providers for social login.
Configure OAuth Providers
authPlugin({
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
enabled: true,
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
enabled: true,
},
discord: {
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
},
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
Enable social providers in your sign-in form:
<SignInForm
authClient={authClient}
showSocialProviders={true}
socialProviders={['github', 'google']}
/>
The form will automatically render OAuth buttons for the specified providers.
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
authPlugin({
emailVerification: {
enabled: true,
sendOnSignUp: true, // Send verification email on sign-up
tokenExpiration: 86400, // 24 hours
},
passwordReset: {
enabled: true,
tokenExpiration: 3600, // 1 hour
},
// Custom email sending function
sendEmail: async ({ to, subject, html }) => {
// Use your email service
await resend.emails.send({
from: 'noreply@yourapp.com',
to,
subject,
html,
})
},
})
Using Resend
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
authPlugin({
sendEmail: async ({ to, subject, html }) => {
await resend.emails.send({
from: 'onboarding@resend.dev',
to,
subject,
html,
})
},
})
Using SendGrid
import sgMail from '@sendgrid/mail'
sgMail.setApiKey(process.env.SENDGRID_API_KEY!)
authPlugin({
sendEmail: async ({ to, subject, html }) => {
await sgMail.send({
from: 'noreply@yourapp.com',
to,
subject,
html,
})
},
})
Development Mode
If no sendEmail function 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
Auto-Generated Lists
The auth plugin automatically creates these lists in your database:
User List
{
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:
- Query: Anyone
- Create: Anyone (sign-up)
- Update: Own user only
- Delete: Own user only
Session List
{
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
}
Account List
Stores OAuth provider information and password hashes:
{
id: string // Auto-generated UUID
userId: string // Foreign key to User
accountId: string // Provider-specific user ID
providerId: string // 'github', 'google', 'email-password'
accessToken: string | null // OAuth access token
refreshToken: string | null // OAuth refresh token
expiresAt: DateTime | null // Token expiration
password: string | null // Hashed password (for email/password)
createdAt: DateTime // Auto-generated
updatedAt: DateTime // Auto-updated
// Relationships
user: User // Account owner
}
Verification List
Stores email verification and password reset tokens:
{
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
}
Best Practices
Security
Use Strong Secrets
bash# Generate with: openssl rand -base64 32Silent Failures Access-denied operations return
nullor[]instead of throwing errors. This prevents information leakage about whether records exist:typescriptconst post = await context.db.post.update({ where: { id }, data }) if (!post) { // Could mean: doesn't exist OR access denied return { error: 'Access denied' } }Never Expose Sensitive Fields
typescriptfields: { password: password(), // Automatically excluded from reads 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:
typescript'use server' export async function createPost(data: unknown) { // Server-side validation const validated = postSchema.parse(data) // ... }
Session Management
Configure Session Expiration
typescriptauthPlugin({ session: { expiresIn: 604800, // 7 days updateAge: true, // Extend expiry on each request }, })Include Only Necessary Fields Don't include sensitive data in session fields:
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
├── 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
└── actions/
└── *.ts # Server actions with auth
opensaas.config.ts # Config with authPlugin
Environment Variables
Always use environment variables for sensitive data:
// 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
Handle auth errors gracefully:
'use client'
export function SignInButton() {
const [error, setError] = useState<string | null>(null)
async function handleSignIn(email: string, password: string) {
try {
await authClient.signIn.email({ email, password })
router.push('/admin')
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
setError('An error occurred')
}
}
}
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:
// opensaas.config.ts
import { config, list, text, select, relationship } from '@opensaas/stack-core'
import { authPlugin } from '@opensaas/stack-auth'
import type { AccessControl } from '@opensaas/stack-core'
// Access control helpers
const isSignedIn: AccessControl = ({ session }) => !!session
const isAuthor: AccessControl = ({ session, item }) => {
if (!session) return false
return { authorId: { equals: session.userId } }
}
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,
}),
},
},
}),
],
db: {
provider: 'sqlite',
url: process.env.DATABASE_URL || 'file:./dev.db',
},
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: () => true,
create: isSignedIn,
update: isAuthor,
delete: isAuthor,
},
filter: {
query: ({ session }) => {
if (!session) {
return { status: { equals: 'published' } }
}
return {
OR: [{ status: { equals: 'published' } }, { authorId: { equals: session.userId } }],
}
},
},
},
}),
},
})
// lib/auth.ts
import { createAuth } from '@opensaas/stack-auth/server'
import config from '../opensaas.config'
import { rawOpensaasContext } from '@/.opensaas/context'
import { headers } from 'next/headers'
export const auth = createAuth(config, rawOpensaasContext)
export async function getAuth() {
const session = await auth.api.getSession({
headers: await headers(),
})
return session
}
export const GET = auth.handler
export const POST = auth.handler
// lib/auth-client.ts
'use client'
import { createClient } from '@opensaas/stack-auth/client'
export const authClient = createClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
})
// app/sign-in/page.tsx
import { SignInForm } from '@opensaas/stack-auth/ui'
import { authClient } from '@/lib/auth-client'
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
authClient={authClient}
redirectTo="/admin"
/>
</div>
)
}
// app/admin/[[...admin]]/page.tsx
import { AdminUI } from '@opensaas/stack-ui'
import { getContext, config } from '@/.opensaas/context'
import { getAuth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function AdminPage({ params, searchParams }) {
const session = await getAuth()
if (!session) {
redirect('/sign-in')
}
return (
<AdminUI
context={await getContext(session.user)}
config={await config}
params={params.admin}
searchParams={searchParams}
/>
)
}
Troubleshooting
"Session is null" in Access Control
Make sure you're passing the session when creating the context:
// ❌ Wrong
const context = await getContext()
// ✅ Correct
const session = await getAuth()
const context = await getContext(session?.user)
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
sendEmailfunction 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?
TypeScript Errors on Session Fields
Make sure custom fields are included in sessionFields:
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 OpenSaaS Stack application!