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 === "luke@charle.co.uk";
          return {
            data: {
              ...user,
              status: isAdminEmail ? "active" : "pending",
            },
          };
        },
        after: async (user) => {
          // Set access level for admin emails
          if (user.email === "luke@charle.co.uk") {
            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 (status: “pending”)
  2. Admin sends invitation email (status: “sent”)
  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, status auto-updated to “active”

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);
      },
    },
  },
  session: {
    create: {
      after: async (session) => {
        // Auto-activate users with "sent" status on first login
        const [userData] = await db
          .select({ status: userTable.status })
          .from(userTable)
          .where(eq(userTable.id, session.userId))
          .limit(1);

        if (userData?.status === "sent") {
          await db
            .update(userTable)
            .set({ status: "active", updatedAt: new Date() })
            .where(eq(userTable.id, session.userId));
        }
      },
    },
  },
},
User Status Flow:
  • pending → Account created but no invite sent
  • sent → Invite email sent, awaiting first login
  • active → User has logged in (auto-activated on first login after invite)

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

Authentication Overview

Auth architecture and systems

Permissions

Role-based access control

Sessions

Session lifecycle management

Better Auth Docs

Official Better Auth documentation