BetterStarter logoBetterStarter
Features

Auth with Better-Auth

Email-based authentication system with Better-Auth.

Overview

BetterStarter uses Better-Auth for authentication. It provides:

  • Email OTP (One-Time Password) - Magic link / 6-digit code
  • OAuth providers - Google, GitHub, etc.
  • Session management - 30-day sessions with sliding window
  • Type safety - Full TypeScript support

Core Concepts

BetterStarter uses email OTP by default:

  1. User enters email
  2. System sends 6-digit code
  3. User enters code to verify
  4. Session is created

No passwords. No signup process. Just email verification.

Configuration

Better-Auth is configured in src/lib/auth.ts:

import { betterAuth } from 'better-auth'
import { emailOTP } from 'better-auth/plugins'

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: 'pg',
  }),
  emailAndPassword: {
    enabled: false, // No passwords
  },
  plugins: [
    emailOTP({
      async sendEmail({ email, code, url }) {
        // Send email via Plunk
      },
    }),
  ],
  session: {
    expiresIn: 60 * 60 * 24 * 30, // 30 days
    updateAge: 60 * 60 * 24, // Refresh daily
    cookieCache: {
      enabled: true,
      maxAge: 5 * 60, // 5 min
    },
  },
})

Session Lifecycle

30-Day Session with Daily Refresh

Day 1:    Session created (expires: Day 31)
Day 2:    New request → Session refreshed (new expiry: Day 32)
Day 30:   User requests page → Session refreshed (expires: Day 60)
Day 31:   No activity → Session expired

Users stay logged in as long as they visit within 30 days.

Authentication Flow

Sign In

import { authClient } from '@/lib/auth-client'

// 1. Request OTP
await authClient.signIn.email({
  email: 'user@example.com',
})

// 2. Enter code from email
const { data, error } = await authClient.verifyEmail({
  email: 'user@example.com',
  code: '123456',
})

if (data.session) {
  // User is now authenticated
}

Sign Out

await authClient.signOut()

Get Current User

In server functions:

import { authMiddleware } from '@/lib/auth/middleware'

export const getUserData = createServerFn({ method: 'GET' })
  .middleware([authMiddleware])
  .handler(async ({ context }) => {
    const user = context.user // Injected by middleware
    if (!user) {
      throw new Error('Unauthorized')
    }
    return user
  })

In components:

import { useAuth } from '@/lib/auth-client'

function MyComponent() {
  const { data: session } = useAuth()
  
  if (!session) {
    return <div>Not logged in</div>
  }
  
  return <div>Welcome, {session.user.email}</div>
}

OAuth Providers

Google OAuth

Setup in src/lib/auth.ts:

import { google } from 'better-auth/social-providers'

export const auth = betterAuth({
  socialProviders: {
    google: {
      clientId: env.GOOGLE_OAUTH_CLIENT_ID,
      clientSecret: env.GOOGLE_OAUTH_CLIENT_SECRET,
    },
  },
})

Sign in:

await authClient.signIn.social({
  provider: 'google',
  callbackURL: '/dashboard',
})

Database Schema

Better-Auth creates these tables automatically:

user
  id, email, name, image, createdAt

session
  id, userId, expiresAt, token

account
  id, userId, provider, accountId, accessToken, refreshToken

verification
  id, identifier, value, expiresAt

All relationships are properly indexed and constrained.

Middleware

The authMiddleware injects the current user into route context:

import { authMiddleware } from '@/lib/auth/middleware'

export const myRoute = createFileRoute('/protected')({
  loader: async (opts) => {
    const user = await authMiddleware(opts)
    if (!user) {
      throw notFound()
    }
    return { user }
  },
})

Email Configuration

Better-Auth sends emails via your configured provider (Plunk by default).

Customize email templates in:

const auth = betterAuth({
  plugins: [
    emailOTP({
      async sendEmail({ email, code, url }) {
        // Send via Plunk
        await sendEmail({
          to: email,
          subject: 'Your sign-in code',
          template: 'sign-in-otp',
          data: { code },
        })
      },
    }),
  ],
})

Error Handling

Common errors and handling:

const { error } = await authClient.signIn.email({
  email: 'user@example.com',
})

if (error?.code === 'INVALID_EMAIL') {
  // Handle invalid email
}

if (error?.code === 'EMAIL_NOT_VERIFIED') {
  // Prompt for code entry
}

Security

Better-Auth handles:

  • ✅ Secure token generation
  • ✅ Rate limiting on email sends
  • ✅ CSRF token generation
  • ✅ HttpOnly cookie storage
  • ✅ Session validation

Additional steps:

  • Always use HTTPS in production
  • Set BETTER_AUTH_SECRET to a random string (openssl rand -base64 32)
  • Keep BETTER_AUTH_URL matching your domain
  • Validate user input before processing

Extended User Data

Add custom fields to the user:

// In your auth configuration
const auth = betterAuth({
  user: {
    additionalFields: {
      role: {
        type: 'string',
        default: 'user',
      },
      bio: {
        type: 'string',
      },
    },
  },
})

// Then access
const user = session.user
// user.role, user.bio available

On this page