Skip to main content

Authentication

KnowledgeCheckr uses Better Auth v1.2.5 for a flexible, secure authentication system supporting multiple providers and anonymous users.

Overview

Better Auth provides:
  • Email/password authentication
  • OAuth social providers (GitHub, Google, Dex)
  • Anonymous user sessions
  • Account linking
  • Session management
  • Rate limiting

Configuration

Location: src/lib/auth/server.ts

Basic Setup

import { betterAuth } from 'better-auth'
import { nextCookies } from 'better-auth/next-js'
import { anonymous, genericOAuth } from 'better-auth/plugins'
import createPool from '@/database/Pool'

export const auth = betterAuth({
  database: createPool(),
  emailAndPassword: {
    enabled: true,
    minPasswordLength: 8,
    autoSignIn: true,
  },
  plugins: [
    nextCookies(),
    anonymous(),
    genericOAuth(),
  ],
})

Database Integration

Better Auth connects directly to MySQL using the connection pool:
database: createPool()
Required Tables:
  • User - User accounts
  • Account - OAuth provider data
  • Session - Active sessions
  • Verification - Email verification tokens
See Database Schema for table definitions.

Table Mapping

user: {
  modelName: 'User',
},
account: {
  modelName: 'Account',
  fields: {
    userId: 'user_id',
  },
},
session: {
  modelName: 'Session',
  fields: {
    userId: 'user_id',
  },
},
verification: {
  modelName: 'Verification',
},

Authentication Methods

Email & Password

Configuration:
emailAndPassword: {
  enabled: true,
  minPasswordLength: 8,
  autoSignIn: true,
}
Features:
  • Minimum 8-character passwords
  • Automatic sign-in after registration
  • Password hashing (handled by Better Auth)
  • Email verification support
Usage Example:
// Sign up
await auth.api.signUp({
  email: '[email protected]',
  password: 'securePassword123',
  name: 'John Doe',
})

// Sign in
await auth.api.signIn({
  email: '[email protected]',
  password: 'securePassword123',
})

Social Authentication

GitHub OAuth

socialProviders: {
  github: {
    clientId: env.AUTH_GITHUB_ID,
    clientSecret: env.AUTH_GITHUB_SECRET,
  },
}
Required Environment Variables:
AUTH_GITHUB_ID=your_github_client_id
AUTH_GITHUB_SECRET=your_github_client_secret
Profile Mapping:
  • Name: GitHub username
  • Email: Primary GitHub email
  • Image: GitHub avatar URL

Google OAuth

socialProviders: {
  google: {
    clientId: env.AUTH_GOOGLE_ID,
    clientSecret: env.AUTH_GOOGLE_SECRET,
  },
}
Required Environment Variables:
AUTH_GOOGLE_ID=your_google_client_id
AUTH_GOOGLE_SECRET=your_google_client_secret
Profile Mapping:
  • Name: Google account name
  • Email: Google email
  • Image: Google profile photo

Generic OAuth (Dex)

Support for custom OAuth providers via the genericOAuth plugin:
genericOAuth({
  config: [
    {
      providerId: 'dex',
      clientId: env.DEX_CLIENT_ID,
      clientSecret: env.DEX_CLIENT_SECRET,
      authentication: 'basic',
      authorizationUrl: `${env.DEX_PROVIDER_URL}/auth`,
      tokenUrl: `${env.DEX_PROVIDER_URL}/token`,
      userInfoUrl: `${env.DEX_PROVIDER_URL}/userinfo`,
      scopes: ['openid', 'email', 'profile'],
      mapProfileToUser(profile) {
        return {
          id: profile.sub,
          name: profile.name || profile.email?.split('@')[0],
          email: profile.email,
          image: profile.picture ?? null,
        }
      },
    },
  ],
})
Required Environment Variables:
DEX_CLIENT_ID=your_dex_client_id
DEX_CLIENT_SECRET=your_dex_client_secret
DEX_PROVIDER_URL=https://dex.example.com
Custom Profile Mapping: The mapProfileToUser function transforms the OAuth provider’s profile format into KnowledgeCheckr’s user schema.

Provider Initialization Utility

Dynamic provider configuration based on environment variables:
const initProvider = <K extends keyof SocialProviders>(
  name: K,
  config: Partial<ProviderConfig<K>[K]>
): ProviderConfig<K> => {
  if (isEmpty(config)) return {} as ProviderConfig<K>
  if (Object.values(config).some((val) => val === undefined)) {
    return {} as ProviderConfig<K>
  }
  
  return { [name]: config } as unknown as ProviderConfig<K>
}

// Usage
socialProviders: {
  ...initProvider('github', { 
    clientId: env.AUTH_GITHUB_ID, 
    clientSecret: env.AUTH_GITHUB_SECRET 
  }),
  ...initProvider('google', { 
    clientId: env.AUTH_GOOGLE_ID, 
    clientSecret: env.AUTH_GOOGLE_SECRET 
  }),
}
Providers are only enabled if all required environment variables are set.

Anonymous Authentication

The anonymous plugin allows users to interact with the platform without registration.

Configuration

plugins: [
  anonymous({
    onLinkAccount: async ({ anonymousUser, newUser }) => {
      logger.info(
        `Anonymous user '${anonymousUser.user.email}' was linked to: '${newUser.user.email}'!`
      )
      
      const db = await getDatabase()
      
      // Transfer ownership of knowledge checks
      const [{ affectedRows: updatedChecks }] = await db
        .update(db_knowledgeCheck)
        .set({ owner_id: newUser.user.id })
        .where(eq(db_knowledgeCheck.owner_id, anonymousUser.user.id))
      
      // Transfer examination results
      const [{ affectedRows: updatedResults }] = await db
        .update(db_userHasDoneKnowledgeCheck)
        .set({ userId: newUser.user.id })
        .where(eq(db_userHasDoneKnowledgeCheck.userId, anonymousUser.user.id))
      
      logger.info(
        `Transferred ${updatedChecks} checks and ${updatedResults} results`
      )
    },
  }),
]

Anonymous User Flow

  1. Creation: User accesses the site without signing in
    • Better Auth creates a temporary user with isAnonymous: true
    • Session is created and tracked via cookies
  2. Usage: Anonymous user can:
    • Create knowledge checks (owned by anonymous user ID)
    • Take practice sessions
    • Take examinations (if allowed by check settings)
  3. Account Linking: When anonymous user signs up/in:
    • onLinkAccount callback is triggered
    • All owned checks are transferred to the new account
    • All examination results are transferred
    • Anonymous user record is deleted (cascade)

Data Migration Example

From src/lib/auth/server.ts:69-82:
try {
  const [{ affectedRows: updatedChecks }] = await db
    .update(db_knowledgeCheck)
    .set({ owner_id: newUser.user.id })
    .where(eq(db_knowledgeCheck.owner_id, anonymousUser.user.id))
    
  const [{ affectedRows: updatedResults }] = await db
    .update(db_userHasDoneKnowledgeCheck)
    .set({ userId: newUser.user.id })
    .where(eq(db_userHasDoneKnowledgeCheck.userId, anonymousUser.user.id))
    
  logger.info(
    `Transferred ${updatedChecks} checks and ${updatedResults} results`
  )
} catch (e) {
  logger.error('Failed to transfer data', e)
}

Session Management

Server-Side Session Retrieval

import { headers } from 'next/headers'
import { auth } from '@/src/lib/auth/server'

export async function getServerSession() {
  const session = await auth.api.getSession({ 
    headers: await headers() 
  })
  
  return session ?? { user: undefined, session: undefined }
}
Usage in Server Components:
import { getServerSession } from '@/src/lib/auth/server'

export default async function ProtectedPage() {
  const { user, session } = await getServerSession()
  
  if (!user) {
    redirect('/login')
  }
  
  return <div>Welcome, {user.name}!</div>
}

Client-Side Session Retrieval

Better Auth provides React hooks (via nextCookies plugin):
import { useSession } from '@/src/lib/auth/client'

export function UserProfile() {
  const { data: session, isPending } = useSession()
  
  if (isPending) return <div>Loading...</div>
  if (!session) return <div>Not logged in</div>
  
  return <div>Hello, {session.user.name}!</div>
}

Session Data Structure

type Session = {
  session: {
    id: string
    token: string
    userId: string
    expiresAt: string
    ipAddress?: string
    userAgent?: string
    createdAt: string
    updatedAt: string
  }
  user: {
    id: string
    name: string
    email: string
    emailVerified: boolean
    image?: string
    isAnonymous?: boolean
    createdAt: string
    updatedAt: string
  }
}
Type export from src/lib/auth/server.ts:109:
export type BetterAuthUser = NonNullable<
  Awaited<ReturnType<typeof auth.api.getSession<boolean>>>
>['user']

Security Features

Rate Limiting

rateLimit: {
  enabled: env.NEXT_PUBLIC_MODE === 'test' ? false : true,
}
  • Disabled in test mode for automated testing
  • Enabled in development and production
  • Protects against brute force attacks
  • Applied to authentication endpoints

Session Security

Better Auth automatically handles:
  • Secure Cookies: httpOnly and secure flags
  • CSRF Protection: Token-based protection
  • Session Expiration: Automatic cleanup
  • IP Tracking: Stored in Session.ipAddress
  • User Agent Tracking: Stored in Session.userAgent

Password Security

  • Minimum 8 characters enforced
  • Automatic hashing (bcrypt/argon2)
  • No plaintext password storage
  • Password reset via verification tokens

API Endpoints

Better Auth automatically creates API routes under /api/auth/*:
  • POST /api/auth/sign-up - Register new user
  • POST /api/auth/sign-in - Login
  • POST /api/auth/sign-out - Logout
  • GET /api/auth/session - Get current session
  • GET /api/auth/callback/:provider - OAuth callbacks
  • POST /api/auth/link-account - Link anonymous to authenticated

Integration with Next.js

Next.js Configuration

From next.config.ts:5-7:
experimental: {
  authInterrupts: true,
}
Enables Better Auth’s interruption handling for authentication flows.

Middleware Setup

Better Auth integrates with Next.js middleware via the nextCookies plugin:
import { nextCookies } from 'better-auth/next-js'

plugins: [
  nextCookies(),
  // ... other plugins
]

Authentication Guards

Server Component Guard

import { getServerSession } from '@/src/lib/auth/server'
import { redirect } from 'next/navigation'

export default async function ProtectedPage() {
  const { user } = await getServerSession()
  
  if (!user) {
    redirect('/login')
  }
  
  if (user.isAnonymous) {
    redirect('/sign-up?message=account_required')
  }
  
  // Protected content
  return <Dashboard user={user} />
}

API Route Guard

import { getServerSession } from '@/src/lib/auth/server'
import { NextResponse } from 'next/server'

export async function GET() {
  const { user } = await getServerSession()
  
  if (!user) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { status: 401 }
    )
  }
  
  // Protected API logic
  return NextResponse.json({ data: 'sensitive data' })
}

Role-Based Access

Check ownership and collaboration:
import getDatabase from '@/database/Database'
import { db_knowledgeCheck } from '@/database/drizzle/schema'
import { eq, or } from 'drizzle-orm'

const db = await getDatabase()
const check = await db.query.db_knowledgeCheck.findFirst({
  where: eq(db_knowledgeCheck.id, checkId),
})

if (check.owner_id !== user.id) {
  // Check if user is a collaborator
  const isCollaborator = await db.query.db_userContributesToKnowledgeCheck.findFirst({
    where: and(
      eq(db_userContributesToKnowledgeCheck.userId, user.id),
      eq(db_userContributesToKnowledgeCheck.knowledgecheckId, checkId)
    ),
  })
  
  if (!isCollaborator) {
    throw new Error('Unauthorized')
  }
}

Logging

Authentication events are logged using Winston:
import _logger from '@/src/lib/log/Logger'

const logger = _logger.createModuleLogger(
  '/' + import.meta.url.split('/').reverse().slice(0, 2).reverse().join('/')!
)

logger.info(`Anonymous user linked to: '${newUser.user.email}'`)
logger.error('Failed to transfer data from anonymous user', error)
See logs in:
  • Development: Console output
  • Production: Winston log files with daily rotation

Environment Variables

Required environment variables:
# GitHub OAuth (optional)
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=

# Google OAuth (optional)
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=

# Dex OAuth (optional)
DEX_CLIENT_ID=
DEX_CLIENT_SECRET=
DEX_PROVIDER_URL=

# Database (required)
DATABASE_HOST=
DATABASE_PORT=
DATABASE_NAME=
DATABASE_USER=
DATABASE_PASSWORD=

# Mode
NEXT_PUBLIC_MODE=development # or production, test

Best Practices

  1. Always validate sessions server-side: Don’t trust client-side session data
  2. Use getServerSession() in Server Components: Avoids unnecessary client-side fetches
  3. Handle anonymous users gracefully: Check user.isAnonymous before requiring registration
  4. Enable rate limiting in production: Protects against abuse
  5. Log authentication events: Helps with debugging and security monitoring
  6. Transfer anonymous data on account linking: Preserve user’s work when they register

Troubleshooting

Sessions Not Persisting

  • Verify cookie settings in browser (httpOnly, secure flags)
  • Check that nextCookies() plugin is enabled
  • Ensure session hasn’t expired

OAuth Provider Not Working

  • Verify environment variables are set
  • Check OAuth app configuration (redirect URIs, scopes)
  • Review Better Auth logs for errors

Anonymous Account Linking Failed

  • Check database foreign key constraints
  • Verify onLinkAccount callback isn’t throwing errors
  • Review Winston logs for transfer errors

Next Steps

Build docs developers (and LLMs) love