Skip to main content
CharleOS uses server-side sessions stored in PostgreSQL. This page explains how sessions work and how to manage them.

Session Architecture

Storage

Sessions are stored in the database, not in JWTs: Benefits:
  • Can be revoked instantly
  • Track session metadata (IP, user agent)
  • No token size limits
  • More secure (no client-side storage)
Tables:
  • session - Team member sessions
  • client_session - Client portal sessions

Session Lifecycle

Creation

Sessions are created when a user signs in:
User signs in

Better Auth creates session record

Session token stored in HTTP-only cookie

User redirected to dashboard
Session Record:
{
  id: "cuid-session-id",
  userId: "user-123",
  token: "random-secure-token",
  expiresAt: "2024-01-30T12:00:00Z",  // 7 days from creation
  ipAddress: "192.168.1.1",
  userAgent: "Mozilla/5.0...",
  createdAt: "2024-01-23T12:00:00Z",
  updatedAt: "2024-01-23T12:00:00Z"
}

Validation

On every request, the session is validated:
1

Extract Token

Session token read from HTTP-only cookie
2

Database Lookup

Query session table for matching token
3

Check Expiry

Verify expiresAt is in the future
4

Load User

Join with user table to get user data
5

Return Session

Return session object with user data

Refresh

Sessions are automatically refreshed on activity:
// lib/auth.ts
session: {
  expiresIn: 60 * 60 * 24 * 7,  // 7 days
  updateAge: 60 * 60 * 24,      // Refresh after 1 day
}
How it works:
  • If session is older than 1 day, it’s refreshed
  • updatedAt timestamp updated
  • expiresAt extended by 7 days
  • User stays logged in without re-authentication

Expiry

Sessions expire automatically:
  • Team sessions: 7 days from last activity
  • Client sessions: 30 days from last activity
Cleanup:
  • Expired sessions are periodically purged from database
  • Better Auth handles this automatically

Revocation

Sessions can be revoked (sign out):
import { signOut } from "@/lib/auth-client";

// Sign out current user
await signOut();
What happens:
  1. Session record deleted from database
  2. Cookie cleared
  3. User redirected to sign-in page

Server-Side Session Access

In Server Components

import { auth } from "@/lib/auth";
import { headers } from "next/headers";

export default async function Page() {
  const { data: session } = await auth.api.getSession({
    headers: headers(),
  });

  if (!session) {
    redirect("/auth");
  }

  // Access user data
  const user = session.user;
  console.log(user.name, user.email, user.accessLevel);

  return <div>Welcome, {user.name}!</div>;
}

In API Routes

import { auth } from "@/lib/auth";
import { headers } from "next/headers";

export async function GET(req: Request) {
  const { data: session } = await auth.api.getSession({
    headers: headers(),
  });

  if (!session) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Use session data
  const userId = session.user.id;

  return Response.json({ user: session.user });
}

In Server Actions

"use server";

import { auth } from "@/lib/auth";
import { headers } from "next/headers";

export async function updateProfile(formData: FormData) {
  const { data: session } = await auth.api.getSession({
    headers: headers(),
  });

  if (!session) {
    throw new Error("Not authenticated");
  }

  // Update user profile...
}

Client-Side Session Access

Using React Hook

"use client";

import { useSession } from "@/lib/auth-client";

export function ProfileButton() {
  const { data: session, isPending } = useSession();

  if (isPending) {
    return <LoadingSpinner />;
  }

  if (!session) {
    return <SignInButton />;
  }

  return (
    <div>
      <img src={session.user.image} alt={session.user.name} />
      <span>{session.user.name}</span>
    </div>
  );
}

Session Object Structure

{
  user: {
    id: string;
    name: string;
    email: string;
    image: string | null;
    // Additional fields
    status: "pending" | "sent" | "active" | "deactivated";
    accessLevel: "admin" | "manager" | "staff";
    workType: "development" | "design" | "pm" | "csm" | "qa" | "sdr" | null;
    hasBillableCapacity: boolean;
    hasExecutiveAccess: boolean;
    birthday: string | null;
    jobTitle: string | null;
  };
  session: {
    id: string;
    userId: string;
    expiresAt: string;
    createdAt: string;
    updatedAt: string;
  };
}

Session Security

HTTP-Only Cookies

Session tokens are stored in HTTP-only cookies: Benefits:
  • Not accessible via JavaScript
  • Protected from XSS attacks
  • Automatically sent with requests
  • Secure flag in production (HTTPS only)
Cookie Configuration:
// Set by Better Auth automatically
{
  name: "auth.session_token",
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "lax",
  path: "/",
  maxAge: 60 * 60 * 24 * 7  // 7 days
}

CSRF Protection

Better Auth includes built-in CSRF protection:
  • All state-changing requests require valid CSRF token
  • Tokens are stored in separate cookie
  • Automatically validated on each request

Session Metadata

CharleOS tracks additional session metadata:
FieldPurpose
ipAddressTrack IP for security audits
userAgentIdentify device/browser
createdAtWhen session was created
updatedAtWhen session was last refreshed

Multi-Device Sessions

Users can be signed in on multiple devices:
// Get all active sessions for a user
const activeSessions = await db
  .select()
  .from(session)
  .where(eq(session.userId, userId))
  .where(gt(session.expiresAt, new Date()));

console.log(`User has ${activeSessions.length} active sessions`);

Revoking All Sessions

// Sign out from all devices
await db
  .delete(session)
  .where(eq(session.userId, userId));

Client Portal Sessions

Client portal sessions work identically but:
  • Use client_session table
  • Longer expiry (30 days vs 7 days)
  • Separate cookie (client_auth.session_token)
// Client portal session access
import { clientAuth } from "@/lib/client-auth";

const { data: session } = await clientAuth.api.getSession({
  headers: headers(),
});

if (!session) {
  redirect("/client-portal/sign-in");
}

// Access client user data
const clientUser = session.user;
const clientId = clientUser.clientId;

Session Management UI

Current Session Info

"use client";

import { useSession } from "@/lib/auth-client";

export function SessionInfo() {
  const { data: session } = useSession();

  if (!session) return null;

  return (
    <div>
      <p>Signed in as: {session.user.email}</p>
      <p>Session expires: {new Date(session.session.expiresAt).toLocaleString()}</p>
    </div>
  );
}

Sign Out Button

"use client";

import { signOut } from "@/lib/auth-client";

export function SignOutButton() {
  return (
    <button onClick={() => signOut()}>
      Sign Out
    </button>
  );
}

Troubleshooting

Session Not Persisting

Symptoms:
  • User signed in but immediately signed out
  • Cookies not being set
Causes:
  1. BETTER_AUTH_URL doesn’t match actual URL
  2. HTTPS/domain mismatch
  3. Browser blocking cookies
Fix:
  1. Verify .env.local has correct URL
  2. Check browser console for cookie warnings
  3. Clear cookies and try again

Session Expired Too Quickly

Symptoms:
  • User signed out before 7 days
Causes:
  • Session not being refreshed
  • updateAge too long
Fix:
  • Check session.updateAge in lib/auth.ts
  • Ensure user is making requests (refreshes session)

Multiple Sign-Outs Required

Symptoms:
  • User signs out but still sees authenticated state
Causes:
  • Client-side cache not cleared
  • SWR still holding session data
Fix:
import { signOut } from "@/lib/auth-client";
import { mutate } from "swr";

// Clear SWR cache on sign out
await signOut();
mutate(() => true, undefined, { revalidate: false });

Best Practices

Never trust client-side session state:
// ✅ Good: Validate on server
const { data: session } = await auth.api.getSession({ headers: headers() });
if (!session) {
  return Response.json({ error: "Unauthorized" }, { status: 401 });
}

// ❌ Bad: Trust client-side check
"use client";
if (session) {
  // Anyone can bypass this
}
Never store session tokens in localStorage or sessionStorage:
  • Better Auth uses HTTP-only cookies by default
  • Not accessible via JavaScript
  • Protected from XSS
Sessions should expire after inactivity:
session: {
  expiresIn: 60 * 60 * 24 * 7,  // 7 days max
  updateAge: 60 * 60 * 24,       // Refresh after 1 day activity
}
Store IP and user agent for security:
  • Helps identify suspicious activity
  • Useful for security audits
  • Better Auth stores this automatically