Skip to main content
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:
  1. Create OAuth credentials in Google Cloud Console
  2. Set authorized redirect URIs:
    • http://localhost:3000/api/auth/callback/google (local)
    • https://charle.agency/api/auth/callback/google (production)
  3. Add credentials to .env.local
Sign-In Flow:
  1. User clicks “Sign in with Google”
  2. Redirected to Google consent screen
  3. Google redirects back to /api/auth/callback/google
  4. Better Auth creates session
  5. 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:
  1. Admin creates user account via admin panel
  2. User receives invitation email
  3. User clicks “Sign in with Google”
  4. Better Auth verifies email exists in database
  5. If not found → “No account found” error
  6. 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

VariablePurpose
BETTER_AUTH_SECRETSecret for encrypting sessions (32+ chars)
BETTER_AUTH_URLBase URL for auth (http://localhost:3000)
NEXT_PUBLIC_BETTER_AUTH_URLPublic URL (client-side)
GOOGLE_CLIENT_IDGoogle OAuth client ID
GOOGLE_CLIENT_SECRETGoogle 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:
  1. Check BETTER_AUTH_URL matches your actual domain
  2. Ensure HTTPS in production
  3. Clear cookies and try again