Better SaaS Docs

Authentication

Better SaaS features a robust authentication system built with Better Auth, supporting multiple OAuth providers, role-based access control, and secure session management.

Overview

The authentication system provides:

  • Multi-Provider OAuth: GitHub, Google, and custom providers
  • Role-Based Access Control: Admin, user, and custom roles
  • Session Management: Secure session handling with Redis
  • Password Security: Bcrypt hashing and security best practices
  • Account Linking: Link multiple OAuth accounts to one user
  • Email Verification: Optional email verification workflow

Better Auth Configuration

Core Configuration

The authentication is configured in src/lib/auth/auth.ts:

import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { db } from "@/server/db"
import { 
  users, 
  sessions, 
  accounts, 
  verificationTokens 
} from "@/server/db/schema"

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "pg",
    schema: {
      users,
      sessions,
      accounts,
      verificationTokens,
    },
  }),
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: false,
  },
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    },
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    },
  },
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days
    updateAge: 60 * 60 * 24, // 24 hours
  },
  user: {
    additionalFields: {
      role: {
        type: "string",
        defaultValue: "user",
      },
      isActive: {
        type: "boolean",
        defaultValue: true,
      },
    },
  },
  plugins: [
    // Add custom plugins here
  ],
})

export type Session = typeof auth.$Infer.Session
export type User = typeof auth.$Infer.User

Client-Side Configuration

Client configuration in src/lib/auth/auth-client.ts:

import { createAuthClient } from "better-auth/react"
import { inferAdditionalFields } from "better-auth/client/plugins"

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_AUTH_URL || "http://localhost:3000",
  plugins: [
    inferAdditionalFields<{
      role: string
      isActive: boolean
    }>(),
  ],
})

export const {
  signIn,
  signOut,
  signUp,
  useSession,
  getSession,
} = authClient

Database Schema

User Schema

// src/server/db/schema.ts
export const users = pgTable("users", {
  id: text("id").primaryKey(),
  name: text("name"),
  email: text("email").unique().notNull(),
  emailVerified: timestamp("emailVerified", { mode: "date" }),
  image: text("image"),
  role: text("role").default("user").notNull(),
  isActive: boolean("isActive").default(true).notNull(),
  createdAt: timestamp("createdAt", { mode: "date" }).defaultNow().notNull(),
  updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow().notNull(),
})

export const sessions = pgTable("sessions", {
  id: text("id").primaryKey(),
  sessionToken: text("sessionToken").unique().notNull(),
  userId: text("userId")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  expires: timestamp("expires", { mode: "date" }).notNull(),
  createdAt: timestamp("createdAt", { mode: "date" }).defaultNow().notNull(),
})

export const accounts = pgTable("accounts", {
  id: text("id").primaryKey(),
  userId: text("userId")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  type: text("type").notNull(),
  provider: text("provider").notNull(),
  providerAccountId: text("providerAccountId").notNull(),
  refresh_token: text("refresh_token"),
  access_token: text("access_token"),
  expires_at: integer("expires_at"),
  token_type: text("token_type"),
  scope: text("scope"),
  id_token: text("id_token"),
  session_state: text("session_state"),
  createdAt: timestamp("createdAt", { mode: "date" }).defaultNow().notNull(),
})

OAuth Providers

GitHub OAuth Setup

  1. Create GitHub OAuth App:

    • Go to GitHub Settings > Developer settings > OAuth Apps
    • Create new OAuth App
    • Set Authorization callback URL: https://yourdomain.com/api/auth/callback/github
  2. Environment Variables:

    GITHUB_CLIENT_ID=your_github_client_id
    GITHUB_CLIENT_SECRET=your_github_client_secret
  3. Usage in Components:

    import { signIn } from "@/lib/auth/auth-client"
    
    export function GitHubSignIn() {
      const handleSignIn = async () => {
        await signIn.social({
          provider: "github",
          callbackURL: "/dashboard",
        })
      }
    
      return (
        <button onClick={handleSignIn}>
          Sign in with GitHub
        </button>
      )
    }

Google OAuth Setup

  1. Create Google OAuth Credentials:

    • Go to Google Cloud Console
    • Create OAuth 2.0 Client ID
    • Add authorized redirect URI: https://yourdomain.com/api/auth/callback/google
  2. Environment Variables:

    GOOGLE_CLIENT_ID=your_google_client_id
    GOOGLE_CLIENT_SECRET=your_google_client_secret
  3. Usage in Components:

    import { signIn } from "@/lib/auth/auth-client"
    
    export function GoogleSignIn() {
      const handleSignIn = async () => {
        await signIn.social({
          provider: "google",
          callbackURL: "/dashboard",
        })
      }
    
      return (
        <button onClick={handleSignIn}>
          Sign in with Google
        </button>
      )
    }

Role-Based Access Control

Permission System

Define permissions in src/lib/auth/permissions.ts:

export const ROLES = {
  ADMIN: "admin",
  USER: "user",
  MODERATOR: "moderator",
} as const

export type Role = typeof ROLES[keyof typeof ROLES]

export const PERMISSIONS = {
  // User permissions
  READ_PROFILE: "read:profile",
  UPDATE_PROFILE: "update:profile",
  
  // Admin permissions
  MANAGE_USERS: "manage:users",
  MANAGE_SETTINGS: "manage:settings",
  VIEW_ANALYTICS: "view:analytics",
  
  // File permissions
  UPLOAD_FILES: "upload:files",
  DELETE_FILES: "delete:files",
  
  // Payment permissions
  MANAGE_SUBSCRIPTIONS: "manage:subscriptions",
  VIEW_BILLING: "view:billing",
} as const

export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS]

export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
  [ROLES.USER]: [
    PERMISSIONS.READ_PROFILE,
    PERMISSIONS.UPDATE_PROFILE,
    PERMISSIONS.UPLOAD_FILES,
    PERMISSIONS.VIEW_BILLING,
  ],
  [ROLES.MODERATOR]: [
    PERMISSIONS.READ_PROFILE,
    PERMISSIONS.UPDATE_PROFILE,
    PERMISSIONS.UPLOAD_FILES,
    PERMISSIONS.DELETE_FILES,
    PERMISSIONS.VIEW_BILLING,
  ],
  [ROLES.ADMIN]: [
    PERMISSIONS.READ_PROFILE,
    PERMISSIONS.UPDATE_PROFILE,
    PERMISSIONS.UPLOAD_FILES,
    PERMISSIONS.DELETE_FILES,
    PERMISSIONS.MANAGE_USERS,
    PERMISSIONS.MANAGE_SETTINGS,
    PERMISSIONS.VIEW_ANALYTICS,
    PERMISSIONS.MANAGE_SUBSCRIPTIONS,
    PERMISSIONS.VIEW_BILLING,
  ],
}

export function hasPermission(role: Role, permission: Permission): boolean {
  return ROLE_PERMISSIONS[role]?.includes(permission) ?? false
}

export function hasAnyPermission(role: Role, permissions: Permission[]): boolean {
  return permissions.some(permission => hasPermission(role, permission))
}

Permission Hooks

Create permission hooks in src/hooks/use-permissions.ts:

import { useSession } from "@/lib/auth/auth-client"
import { hasPermission, hasAnyPermission, type Permission, type Role } from "@/lib/auth/permissions"

export function usePermissions() {
  const { data: session } = useSession()
  const userRole = session?.user?.role as Role

  const checkPermission = (permission: Permission): boolean => {
    if (!userRole) return false
    return hasPermission(userRole, permission)
  }

  const checkAnyPermission = (permissions: Permission[]): boolean => {
    if (!userRole) return false
    return hasAnyPermission(userRole, permissions)
  }

  return {
    hasPermission: checkPermission,
    hasAnyPermission: checkAnyPermission,
    role: userRole,
    isAdmin: userRole === "admin",
    isUser: userRole === "user",
    isModerator: userRole === "moderator",
  }
}

Authentication Components

Login Form

// src/components/blocks/login/login-form.tsx
"use client"

import { useState } from "react"
import { signIn } from "@/lib/auth/auth-client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"

export function LoginForm() {
  const [email, setEmail] = useState("")
  const [password, setPassword] = useState("")
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState("")

  const handleEmailSignIn = async (e: React.FormEvent) => {
    e.preventDefault()
    setIsLoading(true)
    setError("")

    try {
      await signIn.email({
        email,
        password,
        callbackURL: "/dashboard",
      })
    } catch (err) {
      setError("Invalid email or password")
    } finally {
      setIsLoading(false)
    }
  }

  const handleSocialSignIn = async (provider: "github" | "google") => {
    setIsLoading(true)
    try {
      await signIn.social({
        provider,
        callbackURL: "/dashboard",
      })
    } catch (err) {
      setError(`Failed to sign in with ${provider}`)
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <Card className="w-full max-w-md">
      <CardHeader>
        <CardTitle>Sign In</CardTitle>
        <CardDescription>
          Enter your credentials to access your account
        </CardDescription>
      </CardHeader>
      <CardContent className="space-y-4">
        <form onSubmit={handleEmailSignIn} className="space-y-4">
          <div className="space-y-2">
            <Label htmlFor="email">Email</Label>
            <Input
              id="email"
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
            />
          </div>
          <div className="space-y-2">
            <Label htmlFor="password">Password</Label>
            <Input
              id="password"
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
            />
          </div>
          {error && <p className="text-sm text-red-600">{error}</p>}
          <Button type="submit" className="w-full" disabled={isLoading}>
            {isLoading ? "Signing in..." : "Sign In"}
          </Button>
        </form>

        <div className="relative">
          <div className="absolute inset-0 flex items-center">
            <span className="w-full border-t" />
          </div>
          <div className="relative flex justify-center text-xs uppercase">
            <span className="bg-background px-2 text-muted-foreground">
              Or continue with
            </span>
          </div>
        </div>

        <div className="grid grid-cols-2 gap-4">
          <Button
            variant="outline"
            onClick={() => handleSocialSignIn("github")}
            disabled={isLoading}
          >
            GitHub
          </Button>
          <Button
            variant="outline"
            onClick={() => handleSocialSignIn("google")}
            disabled={isLoading}
          >
            Google
          </Button>
        </div>
      </CardContent>
    </Card>
  )
}

Sign Up Form

// src/components/blocks/signup/signup-form.tsx
"use client"

import { useState } from "react"
import { signUp } from "@/lib/auth/auth-client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"

export function SignUpForm() {
  const [formData, setFormData] = useState({
    name: "",
    email: "",
    password: "",
    confirmPassword: "",
  })
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState("")

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setIsLoading(true)
    setError("")

    if (formData.password !== formData.confirmPassword) {
      setError("Passwords do not match")
      setIsLoading(false)
      return
    }

    try {
      await signUp.email({
        email: formData.email,
        password: formData.password,
        name: formData.name,
        callbackURL: "/dashboard",
      })
    } catch (err) {
      setError("Failed to create account")
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <Card className="w-full max-w-md">
      <CardHeader>
        <CardTitle>Create Account</CardTitle>
        <CardDescription>
          Enter your information to create a new account
        </CardDescription>
      </CardHeader>
      <CardContent>
        <form onSubmit={handleSubmit} className="space-y-4">
          <div className="space-y-2">
            <Label htmlFor="name">Full Name</Label>
            <Input
              id="name"
              value={formData.name}
              onChange={(e) => setFormData({ ...formData, name: e.target.value })}
              required
            />
          </div>
          <div className="space-y-2">
            <Label htmlFor="email">Email</Label>
            <Input
              id="email"
              type="email"
              value={formData.email}
              onChange={(e) => setFormData({ ...formData, email: e.target.value })}
              required
            />
          </div>
          <div className="space-y-2">
            <Label htmlFor="password">Password</Label>
            <Input
              id="password"
              type="password"
              value={formData.password}
              onChange={(e) => setFormData({ ...formData, password: e.target.value })}
              required
            />
          </div>
          <div className="space-y-2">
            <Label htmlFor="confirmPassword">Confirm Password</Label>
            <Input
              id="confirmPassword"
              type="password"
              value={formData.confirmPassword}
              onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
              required
            />
          </div>
          {error && <p className="text-sm text-red-600">{error}</p>}
          <Button type="submit" className="w-full" disabled={isLoading}>
            {isLoading ? "Creating Account..." : "Create Account"}
          </Button>
        </form>
      </CardContent>
    </Card>
  )
}

Route Protection

Auth Guard Component

// src/components/auth-guard.tsx
"use client"

import { useSession } from "@/lib/auth/auth-client"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { LoadingSkeleton } from "@/components/loading-skeleton"

interface AuthGuardProps {
  children: React.ReactNode
  requireAuth?: boolean
  requiredRole?: string
  fallbackUrl?: string
}

export function AuthGuard({ 
  children, 
  requireAuth = true, 
  requiredRole,
  fallbackUrl = "/login" 
}: AuthGuardProps) {
  const { data: session, isPending } = useSession()
  const router = useRouter()

  useEffect(() => {
    if (isPending) return

    if (requireAuth && !session) {
      router.push(fallbackUrl)
      return
    }

    if (requiredRole && session?.user?.role !== requiredRole) {
      router.push("/unauthorized")
      return
    }
  }, [session, isPending, requireAuth, requiredRole, router, fallbackUrl])

  if (isPending) {
    return <LoadingSkeleton />
  }

  if (requireAuth && !session) {
    return null
  }

  if (requiredRole && session?.user?.role !== requiredRole) {
    return null
  }

  return <>{children}</>
}

Admin Guard Component

// src/components/admin-guard.tsx
"use client"

import { usePermissions } from "@/hooks/use-permissions"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { LoadingSkeleton } from "@/components/loading-skeleton"

interface AdminGuardProps {
  children: React.ReactNode
  fallbackUrl?: string
}

export function AdminGuard({ children, fallbackUrl = "/dashboard" }: AdminGuardProps) {
  const { isAdmin, role } = usePermissions()
  const router = useRouter()

  useEffect(() => {
    if (role && !isAdmin) {
      router.push(fallbackUrl)
    }
  }, [isAdmin, role, router, fallbackUrl])

  if (!role) {
    return <LoadingSkeleton />
  }

  if (!isAdmin) {
    return null
  }

  return <>{children}</>
}

Session Management

Session Provider

// src/components/providers/auth-provider.tsx
"use client"

import { SessionProvider } from "@/lib/auth/auth-client"

export function AuthProvider({ children }: { children: React.ReactNode }) {
  return <SessionProvider>{children}</SessionProvider>
}

Session Hook Usage

// Example component using session
"use client"

import { useSession } from "@/lib/auth/auth-client"
import { signOut } from "@/lib/auth/auth-client"

export function UserProfile() {
  const { data: session, isPending } = useSession()

  if (isPending) return <div>Loading...</div>
  if (!session) return <div>Not authenticated</div>

  return (
    <div>
      <h1>Welcome, {session.user.name}!</h1>
      <p>Email: {session.user.email}</p>
      <p>Role: {session.user.role}</p>
      <button onClick={() => signOut()}>
        Sign Out
      </button>
    </div>
  )
}

Server Actions

Authentication Actions

// src/server/actions/auth-actions.ts
"use server"

import { auth } from "@/lib/auth/auth"
import { db } from "@/server/db"
import { users } from "@/server/db/schema"
import { eq } from "drizzle-orm"
import { revalidatePath } from "next/cache"

export async function updateUserRole(userId: string, newRole: string) {
  const session = await auth.api.getSession({
    headers: headers(),
  })

  if (!session || session.user.role !== "admin") {
    throw new Error("Unauthorized")
  }

  await db
    .update(users)
    .set({ role: newRole })
    .where(eq(users.id, userId))

  revalidatePath("/admin/users")
}

export async function deactivateUser(userId: string) {
  const session = await auth.api.getSession({
    headers: headers(),
  })

  if (!session || session.user.role !== "admin") {
    throw new Error("Unauthorized")
  }

  await db
    .update(users)
    .set({ isActive: false })
    .where(eq(users.id, userId))

  revalidatePath("/admin/users")
}

Security Best Practices

Environment Variables

# Required for Better Auth
BETTER_AUTH_SECRET=your-super-secret-key-here
BETTER_AUTH_URL=https://yourdomain.com

# OAuth Providers
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret

# Database
DATABASE_URL=postgresql://user:password@localhost:5432/bettersaas

Security Headers

// next.config.ts
const securityHeaders = [
  {
    key: 'X-Frame-Options',
    value: 'DENY'
  },
  {
    key: 'X-Content-Type-Options',
    value: 'nosniff'
  },
  {
    key: 'Referrer-Policy',
    value: 'strict-origin-when-cross-origin'
  }
]

module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: securityHeaders,
      },
    ]
  },
}

Testing Authentication

Unit Tests

// src/lib/auth/permissions.test.ts
import { describe, it, expect } from 'vitest'
import { hasPermission, ROLES, PERMISSIONS } from './permissions'

describe('Permission System', () => {
  it('should grant admin all permissions', () => {
    expect(hasPermission(ROLES.ADMIN, PERMISSIONS.MANAGE_USERS)).toBe(true)
    expect(hasPermission(ROLES.ADMIN, PERMISSIONS.VIEW_ANALYTICS)).toBe(true)
  })

  it('should restrict user permissions', () => {
    expect(hasPermission(ROLES.USER, PERMISSIONS.MANAGE_USERS)).toBe(false)
    expect(hasPermission(ROLES.USER, PERMISSIONS.READ_PROFILE)).toBe(true)
  })
})

Integration Tests

// tests/integration/auth.test.ts
import { describe, it, expect } from 'vitest'
import { testClient } from '../utils/test-client'

describe('Authentication API', () => {
  it('should create user account', async () => {
    const response = await testClient.post('/api/auth/signup', {
      email: 'test@example.com',
      password: 'password123',
      name: 'Test User'
    })

    expect(response.status).toBe(201)
    expect(response.data.user.email).toBe('test@example.com')
  })

  it('should sign in user', async () => {
    const response = await testClient.post('/api/auth/signin', {
      email: 'test@example.com',
      password: 'password123'
    })

    expect(response.status).toBe(200)
    expect(response.data.user).toBeDefined()
  })
})

Troubleshooting

Common Issues

  1. OAuth Callback Errors

    • Verify callback URLs in OAuth provider settings
    • Check environment variables are set correctly
    • Ensure BETTER_AUTH_URL matches your domain
  2. Session Not Persisting

    • Check database connection
    • Verify session table exists
    • Check Redis connection if using Redis sessions
  3. Permission Denied

    • Verify user role in database
    • Check permission configuration
    • Ensure auth middleware is properly configured

Debug Commands

# Check user sessions
psql -d bettersaas -c "SELECT * FROM sessions WHERE userId = 'user-id';"

# Check user roles
psql -d bettersaas -c "SELECT id, email, role FROM users;"

# Test OAuth endpoints
curl -X GET http://localhost:3000/api/auth/providers