CharleOS uses Better Auth for modern, type-safe authentication. This page explains the configuration and features.
Why Better Auth?
Type-Safe
Full TypeScript support with automatic type inference
Framework Agnostic
Works with any framework, but optimized for Next.js
Database-First
Uses your existing database (PostgreSQL via Drizzle)
Flexible
Easy to customize and extend
Configuration
Team Auth (lib/auth.ts)
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema,
}),
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
disableSignUp: true, // Invite-only
overrideUserInfoOnSignIn: true, // Update avatar on each login
mapProfileToUser: async (profile) => {
// Download and store Google profile picture in R2
const r2AvatarUrl = await uploadAvatarFromUrl(
profile.sub,
profile.picture
);
return r2AvatarUrl ? { image: r2AvatarUrl } : {};
},
},
},
user: {
additionalFields: {
status: { type: "string", defaultValue: "pending" },
birthday: { type: "string", required: false },
jobTitle: { type: "string", required: false },
},
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Refresh after 1 day
},
databaseHooks: {
user: {
create: {
before: async (user) => {
// Auto-assign admin to Luke
const isAdminEmail = user.email === "[email protected]";
return {
data: {
...user,
status: isAdminEmail ? "active" : "pending",
},
};
},
after: async (user) => {
// Set access level for admin emails
if (user.email === "[email protected]") {
await db
.update(userTable)
.set({ accessLevel: "admin" })
.where(eq(userTable.id, user.id));
}
},
},
},
},
});
Client Portal Auth (lib/client-auth.ts)
import { betterAuth } from "better-auth";
export const clientAuth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema: {
user: clientUser,
session: clientSession,
account: clientAccount,
verification: clientVerification,
},
}),
emailAndPassword: {
enabled: true, // Password-based auth
requireEmailVerification: false,
},
session: {
expiresIn: 60 * 60 * 24 * 30, // 30 days (longer for clients)
},
});
API Handler
Better Auth provides a catch-all API handler:
// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth.handler);
This creates all auth endpoints automatically:
/api/auth/sign-in/google
/api/auth/sign-out
/api/auth/session
/api/auth/callback/google
Client-Side Hooks
Team Auth Client (lib/auth-client.ts)
import { createAuthClient } from "better-auth/client";
export const {
signIn,
signOut,
useSession
} = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL,
});
Usage:
"use client";
import { signIn, signOut, useSession } from "@/lib/auth-client";
export function AuthButton() {
const { data: session, isPending } = useSession();
if (isPending) return <LoadingSpinner />;
if (!session) {
return (
<button onClick={() => signIn.social({ provider: "google" })}>
Sign in with Google
</button>
);
}
return (
<button onClick={() => signOut()}>
Sign out
</button>
);
}
Features
Google OAuth Integration
Configuration:
- Create OAuth credentials in Google Cloud Console
- Set authorized redirect URIs:
http://localhost:3000/api/auth/callback/google (local)
https://charle.agency/api/auth/callback/google (production)
- Add credentials to
.env.local
Sign-In Flow:
- User clicks “Sign in with Google”
- Redirected to Google consent screen
- Google redirects back to
/api/auth/callback/google
- Better Auth creates session
- User redirected to dashboard
Avatar Handling:
- Google profile picture is downloaded
- Uploaded to Cloudflare R2
- Stored in
user.image field
- Updated on every sign-in
Invite-Only System
// lib/auth.ts
socialProviders: {
google: {
disableSignUp: true, // Prevents OAuth signup
// ... rest of config
},
}
How it works:
- Admin creates user account via admin panel
- User receives invitation email
- User clicks “Sign in with Google”
- Better Auth verifies email exists in database
- If not found → “No account found” error
- If found → Session created
Session Management
Server-Side:
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
const { data: session } = await auth.api.getSession({
headers: headers(),
});
if (!session) {
redirect("/auth");
}
// Access user data
const user = session.user;
Client-Side:
import { useSession } from "@/lib/auth-client";
const { data: session, isPending } = useSession();
Database Hooks
Better Auth allows you to hook into lifecycle events:
databaseHooks: {
user: {
create: {
before: async (user) => {
// Modify user before saving
return { data: { ...user, status: "pending" } };
},
after: async (user) => {
// Run logic after user is created
await sendWelcomeEmail(user.email);
},
},
},
},
Security
CSRF Protection
Better Auth includes built-in CSRF protection:
- All POST requests require valid CSRF token
- Tokens are stored in cookies
- Automatically validated on each request
Session Security
- Sessions stored server-side in PostgreSQL
- Random, unpredictable session tokens
- Automatic expiry after 7 days
- IP address and user agent tracked
Password Security (Client Portal)
- Passwords hashed with bcrypt (cost factor 10)
- Never stored in plain text
- Password reset requires email verification
Customization
Adding Custom Fields
user: {
additionalFields: {
birthday: {
type: "string",
required: false,
input: true, // Allow user to update
},
slackUserId: {
type: "string",
required: false,
input: false, // Admin-only field
},
},
},
Custom Session Data
const session = await auth.api.getSession({
headers: headers(),
});
// Access custom fields
const birthday = session.user.birthday;
const slackUserId = session.user.slackUserId;
Environment Variables
| Variable | Purpose |
|---|
BETTER_AUTH_SECRET | Secret for encrypting sessions (32+ chars) |
BETTER_AUTH_URL | Base URL for auth (http://localhost:3000) |
NEXT_PUBLIC_BETTER_AUTH_URL | Public URL (client-side) |
GOOGLE_CLIENT_ID | Google OAuth client ID |
GOOGLE_CLIENT_SECRET | Google OAuth client secret |
Never commit secrets to git. Use .env.local for local dev and Vercel environment variables for production.
Troubleshooting
”No account found” Error
Cause: User trying to sign in with Google but email doesn’t exist in database.
Fix: Admin must create user account first via admin panel.
CORS Errors
Cause: BETTER_AUTH_URL doesn’t match actual URL.
Fix: Verify .env.local has correct URL for environment:
- Local:
http://localhost:3000
- Production:
https://charle.agency
Session Not Persisting
Cause: Cookies not being set (usually HTTPS/domain issues).
Fix:
- Check
BETTER_AUTH_URL matches your actual domain
- Ensure HTTPS in production
- Clear cookies and try again