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.
Two-Factor Authentication Operations
The twoFactor() plugin is the source of truth for account-level TOTP and recovery-code state. The security settings screen calls Better Auth through authClient.twoFactor and does not persist secrets in browser storage or URL parameters.
The supported enrollment sequence is:
- Confirm the current password with
twoFactor.enable. - Store the returned setup URI in an authenticator app. Treat the URI and secret as sensitive and do not log or share them.
- Submit the six-digit authenticator code with
twoFactor.verifyTotp. The account is not considered enabled until this verification succeeds. - Save the returned backup codes in a password manager. Each recovery code is intended for one-time use; regenerate the set if it is lost or exposed.
Disabling 2FA and regenerating recovery codes require the current password. The UI only displays recovery codes in the immediate response state; it does not persist them client-side. Better Auth owns the server-side validation, storage, and consumption of TOTP and recovery credentials.
The application proxy also applies same-origin checks to unsafe /api/* and /auth/* requests and rate-limits /api/auth/* and /api/v1/* by client bucket. These controls reduce browser-CSRF and brute-force exposure, but they are not substitutes for route-level authentication, tenant authorization, or provider-backed integration testing. Before production rollout, verify the installed Better Auth adapter against a disposable PostgreSQL database for login challenge enforcement, recovery-code one-time use, re-authentication, and cross-organization isolation.
For rollout, generate and apply the Better Auth schema migration with bun run auth:generate and bun run auth:migrate after reviewing the generated SQL. Take the normal database backup before applying it. If rollback is required, disable the settings entry point and revert the application deployment first; do not manually delete Better Auth tables or invalidate sessions without an approved, tested migration plan.
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.