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
Magic Links vs OTP
BetterStarter uses email OTP by default:
- User enters email
- System sends 6-digit code
- User enters code to verify
- 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 expiredUsers 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, expiresAtAll 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_SECRETto a random string (openssl rand -base64 32) - Keep
BETTER_AUTH_URLmatching 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