Authentication & Sessions
Learn how the SaaS Boilerplate handles user authentication, session management, and organization-based access control using Better Auth.
By the end of this guide, you'll understand how authentication works in the SaaS Boilerplate, how sessions manage user state and organization context, and how to implement secure access control in your features.
Before You Begin
- Basic Knowledge: Familiarity with web authentication concepts and TypeScript
- Environment Setup: A running instance with Better Auth configured (see Environment Setup)
- Database: PostgreSQL with migrations applied
- Optional: OAuth provider credentials for social login
Core Concepts
The SaaS Boilerplate uses Better Auth as its authentication foundation, enhanced with organization management and multi-tenant access control. This creates a robust system where users can authenticate, manage multiple organizations, and access resources securely.
Authentication Methods
The system supports multiple authentication flows to accommodate different user preferences and security needs:
- Email & Password: Traditional username/password authentication
- Email OTP: One-time passwords sent via email for passwordless login
- Social Providers: OAuth integration with GitHub and Google
- Two-Factor Authentication: TOTP-based 2FA for enhanced security
- API Keys: Programmatic access for integrations and automation
Session Management
Sessions are the cornerstone of user state management. Each session contains:
- User Identity: Basic user information and metadata
- Organization Context: The currently active organization and user's role within it
- Billing Information: Current subscription status and payment details
- Security Tokens: Encrypted session data stored in HTTP-only cookies
Sessions automatically handle organization switching, ensuring all subsequent requests respect the active organization's boundaries.
Organization-Based Access Control
Unlike simple user-based permissions, the SaaS Boilerplate implements organization-scoped access:
- Multi-Tenancy: Each organization operates as an isolated tenant
- Role-Based Permissions: Users have different roles (owner, admin, member) within organizations
- Data Isolation: All business data is automatically scoped by organization ID
- Membership Management: Users can belong to multiple organizations with different roles
API Key Authentication
For programmatic access, the system supports API keys that bypass traditional user sessions:
- Organization-Scoped: API keys belong to organizations, not individual users
- Role Requirements: Keys can only access endpoints requiring specific organization roles
- Expiration Control: Keys can be set to never expire or have custom expiration dates
- Audit Trail: All API key usage is logged for security monitoring
Data Models
The authentication system defines several TypeScript interfaces that govern how sessions and permissions work throughout the application.
Prop
Type
Implementation Details
The authentication system is implemented through a layered architecture that provides both low-level auth services and high-level procedures for business logic.
Configure Better Auth Service
The foundation is set up in src/services/auth.ts, which initializes Better Auth with all necessary plugins and configurations.
export const auth = betterAuth({
baseURL: Url.get(),
secret: AppConfig.providers.auth.secret,
database: prismaAdapter(prisma, { provider: 'postgresql' }),
socialProviders: {
github: {
clientId: AppConfig.providers.auth.providers.github.clientId,
clientSecret: AppConfig.providers.auth.providers.github.clientSecret,
},
google: {
clientId: AppConfig.providers.auth.providers.google.clientId,
clientSecret: AppConfig.providers.auth.providers.google.clientSecret,
},
},
account: {
accountLinking: {
enabled: true,
},
},
plugins: [
twoFactor(),
organization({
sendInvitationEmail: async ({ email, organization, id }) => {
await mail.send({
to: email,
template: 'organization-invite',
data: {
email,
organization: organization.name,
url: Url.get(`/auth?invitation=${id}`),
},
})
},
}),
emailOTP({
async sendVerificationOTP({ email, otp, type }) {
const subjectMap = {
'sign-in': 'Your Access Code',
'email-verification': 'Verify Your Email',
'forget-password': 'Password Recovery',
default: 'Verification Code',
}
const subject = subjectMap[type] || subjectMap.default
await mail.send({
to: email,
subject,
template: 'otp-code',
data: {
email,
otpCode: otp,
expiresInMinutes: 10,
},
})
},
}),
nextCookies(),
],
})This configuration enables social login, email OTP, two-factor authentication, and organization management with invitation emails.
Inject Authentication Context
The AuthFeatureProcedure wraps all authenticated endpoints, providing a unified authentication context that includes session management, organization switching, and role validation.
export const AuthFeatureProcedure = igniter.procedure({
name: 'AuthFeatureProcedure',
handler: async (options, { request, response, context }) => {
return {
auth: {
setActiveOrganization: async (input: { organizationId: string }) => {
// Business Logic: Switch the user's active organization using the auth service
await tryCatch(
context.services.auth.api.setActiveOrganization({
body: input,
headers: request.headers,
}),
)
},
// ... other auth methods
getSession: async (options?: GetSessionInput<TRequirements, TRoles>) => {
// Complex session retrieval logic with role validation
},
},
}
},
})Create Authentication Controllers
Controllers expose authentication endpoints that handle sign-in flows, session management, and organization switching.
signInWithProvider: igniter.mutation({
name: 'signInWithProvider',
description: 'Sign in with OAuth provider',
method: 'POST',
path: '/sign-in',
use: [AuthFeatureProcedure()],
body: z.object({
provider: z.string(),
callbackURL: z.string().optional(),
}),
handler: async ({ request, response, context }) => {
const { provider, callbackURL } = request.body
const result = await context.auth.signInWithProvider({
provider: provider as AccountProvider,
callbackURL,
})
if (result.error) {
throw new Error(result.error.code)
}
return response.success(result.data)
},
}),Implement Role-Based Access Control
In your business logic controllers, use the authentication context to enforce permissions and data isolation.
list: igniter.query({
name: 'List',
description: 'List all leads for an organization.',
path: '/',
use: [AuthFeatureProcedure(), LeadProcedure()],
handler: async ({ context, response }) => {
const session = await context.auth.getSession({
requirements: 'authenticated',
roles: ['admin', 'owner', 'member'],
})
if (!session || !session.organization) {
return response.unauthorized(
'Authentication required and active organization needed.',
)
}
const organizationId = session.organization.id
const leads = await context.lead.findMany(organizationId)
return response.success(leads)
},
}),Practical Examples
Let's see how authentication integrates with real business features in the SaaS Boilerplate.
Lead Management with Organization Scoping
The lead feature demonstrates how authentication ensures data isolation between organizations. Each lead operation automatically scopes to the user's active organization.
create: igniter.mutation({
name: 'Create',
description: 'Create a new lead.',
path: '/',
method: 'POST',
use: [
AuthFeatureProcedure(),
LeadProcedure(),
IntegrationFeatureProcedure(),
],
body: LeadCreationSchema,
handler: async ({ context, request, response }) => {
const session = await context.auth.getSession({
requirements: 'authenticated',
roles: ['admin', 'owner', 'member'],
})
if (!session || !session.organization) {
return response.unauthorized(
'Authentication required and active organization needed.',
)
}
const organizationId = session.organization.id
const { email, name, phone, metadata } = request.body
const lead = await context.lead.create(organizationId, {
email,
name,
phone,
metadata,
})
return response.success(lead)
},
}),API Key Authentication for Integrations
For programmatic access, API keys provide organization-scoped authentication without user sessions.
getSession: async (options?: GetSessionInput<TRequirements, TRoles>) => {
const session = await context.services.auth.api.getSession({
headers: request.headers,
})
// Security Rule: Check for API Key authentication if no regular session exists
let apiKeyOrganization = null
if (!session) {
const authHeader = request.headers.get('Authorization')
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7)
const apiKey = await context.services.database.apiKey.findUnique({
where: {
key: token,
enabled: true,
},
include: {
organization: true,
},
})
if (apiKey) {
if (
!apiKey.neverExpires &&
apiKey.expiresAt &&
new Date() > apiKey.expiresAt
) {
throw new Error('API_KEY_EXPIRED')
}
if (!options?.roles || options.roles.length === 0) {
throw new Error('API_KEY_REQUIRES_ORGANIZATION_ENDPOINT')
}
apiKeyOrganization = apiKey.organization
}
}
}
// ... rest of session logic
}Error Codes
The authentication system provides specific error codes to help developers understand and handle different authentication scenarios. These errors are thrown during session validation and can be caught and handled appropriately in your application.
Prop
Type
Troubleshooting
Best Practices
Organizations & Tenancy
Understand multi-tenant isolation, membership roles, and how the active org shapes access control.
Roles & Permissions
Learn how role-based access control (RBAC) works in the SaaS Boilerplate, including membership roles, permission validation, and organization-scoped access control.